diff --git a/.gitignore b/.gitignore index c95a368..0bcf220 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,8 @@ old_version/ __pycache__/ *.py[cod] -# Laufzeitdaten (Spielername, lokale Ergebnisse, Logs) -runtime/ +# Lokale Logs +# (keine Laufzeitdaten mehr) *.log # Editor- und OS-Kram diff --git a/README.md b/README.md index 5ccb550..fa800e9 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Habegger Trailer Load Kofferlager-Spiel für Messe-/Eventbetrieb: Koffer laufen von rechts ein und -müssen im Auflieger möglichst dicht gestapelt werden. Ziel ist eine hohe -Beladung in Prozent. +müssen im Auflieger möglichst dicht gestapelt werden. Angezeigt wird am Ende +die Stufe und die erreichte Beladung in Prozent. Neu aufgesetzt für macOS (Apple Silicon) mit Controller-Unterstützung. Die Analyse des Altstands und die Liste der behobenen Fehler stehen in @@ -27,68 +27,67 @@ automatisch. Ändert sich `requirements.txt`, wird neu installiert. Auf macOS lässt sich das Spiel auch per Doppelklick auf **`run.command`** starten (beim ersten Mal ggf. Rechtsklick → Öffnen wegen Gatekeeper). -Optionen: - | Option | Bedeutung | | --- | --- | | `--fullscreen` | Vollbild starten (F11 schaltet jederzeit um) | -| `--name "Anna"` | Spielername setzen (sonst der zuletzt gespeicherte) | | `--seed 42` | feste Zufallsfolge, reproduzierbare Koffer-Sequenzen | -| `--smoke 10` | Selbsttest: bestätigt das Intro automatisch, spielt 10 s ohne Eingaben und beendet sich | +| `--smoke 10` | Selbsttest: spielt 10 s ohne Eingaben und beendet sich | + +## Ablauf + +1. **Tutorialbild** (nur beim Programmstart) – weiter mit A / Space +2. **Stufenauswahl**: Easy / Normal / Hard (Hard = Tempo des Altstands) +3. **Ampel-Start** (rot/gelb/grün + Reissverschluss), dann läuft die Runde +4. **Ergebnis**: Stufe und Beladung in Prozent, „Nochmals?" → A startet neu + bei der Stufenauswahl + +Beendet wird ausschliesslich mit **Esc** (Controller: B). ## Steuerung | Aktion | Tastatur | Controller (Xbox-Layout) | | --- | --- | --- | -| Einrasten an der Barriere | `Space` / `Enter` | A | +| Stufe wählen | `←` / `→` | D-Pad links/rechts | +| Bestätigen / Einrasten an der Barriere | `Space` / `Enter` | A | | Koffer drehen (wenn er passt) | `←` | X / D-Pad links | | Auf einen Koffer obendrauf setzen | `↑` | Y / D-Pad hoch | -| Spiel beenden | `Esc` | B | +| Beenden | `Esc` | B | | Vollbild umschalten | `F11` | – | -Stick und D-Pad werden beide ausgewertet, Controller-Hotplug funktioniert -(der Name des erkannten Controllers steht beim Start im Terminal). +Ein gedrehter Koffer behält seine Grafik – er wird nur um 90° gedreht +gezeichnet, nie durch eine andere Textur ersetzt. ## Projektstruktur ``` -main.py Einstiegspunkt (Fenster, Hauptschleife, CLI) +main.py Einstiegspunkt (Phasen: Intro → Menü → Runde) run.sh / run.command Start inkl. venv + Installation requirements.txt pygame case_sequences.txt Koffersequenzen (Datenbestand) loader_game/ - config.py Masse, Tempi, Farben, Texte, Pfade + config.py Masse, Farben, Texte, Pfade + difficulty.py Stufen Easy/Normal/Hard (Tempo) assets.py Bilder/Sounds/Fonts laden und cachen sequences.py Einlesen und Validieren der Sequenzdatei - case.py Koffer: Geometrie und Einlaufbewegung + case.py Koffer: Geometrie, Textur, Bewegung trailer.py Stapelregeln im Auflieger (reine Logik) input_map.py Tastatur/Controller → Action scene.py Zeichnen + menu.py Stufenauswahl intro.py Tutorialbild, Ampel, Reissverschluss - game.py Zustandsmaschine des Spiels - storage.py Laufzeitdaten (letzter Spielername) - net.py optionaler Punkte-Upload -tests/test_logic.py 35 Tests (ohne Fenster) + game.py Zustandsmaschine einer Runde +tests/test_logic.py 48 Tests (ohne Fenster) docs/ANALYSE.md Analyse Altstand + Fehlerliste old_version/ eingefrorener Altstand (nicht versioniert) -runtime/ Laufzeitdaten (nicht versioniert) ``` ## Konfiguration -Alle Werte stehen in `loader_game/config.py` – Auflösung, Bänder, Auflieger, -Tempo, Farben, Texte. Gezeichnet wird immer in der virtuellen Auflösung -1920×1080; `pygame.SCALED` skaliert automatisch auf die tatsächliche -Fenstergrösse (Retina, 1440×900, 4K …). - -Punkte-Upload (standardmässig aus): - -```bash -export HAG_SCORE_URL="https://…/api/submit_points.php" -export HAG_SCORE_API_KEY="…" -``` - -Zugangsdaten gehören in die Umgebung, **nicht** in den Quellcode. +Alle Werte stehen in `loader_game/config.py` (Layout, Farben, Texte, Tempo der +Animationen) und `loader_game/difficulty.py` (Einlaufgeschwindigkeit der drei +Stufen). Gezeichnet wird immer in der virtuellen Auflösung 1920×1080; +`pygame.SCALED` skaliert automatisch auf die tatsächliche Fenstergrösse +(Retina, 1440×900, 4K …). ## Sequenzdatei @@ -109,23 +108,19 @@ Ungültige Zeilen werden beim Start mit Meldung übersprungen. .venv/bin/python -m unittest discover -s tests -v ``` -Getestet werden Sequenz-Parsing, Layout-Invarianten, Kofferbewegung, -Stapelregeln, komplette Spieldurchläufe (bis Spielende) und die -Laufzeitdaten. Kein Fenster, kein Ton nötig. - -## Laufzeitdaten - -`runtime/` (in `.gitignore`): zuletzt verwendeter Spielername. Per -`HAG_RUNTIME_DIR` umlenkbar (nutzen die Tests). +Getestet werden Sequenz-Parsing, Layout-Invarianten, Kofferbewegung +(inkl. Stufen-Tempo), Stapelregeln, Menü-Navigation, komplette Spieldurchläufe +bis zum Ergebnis sowie das Rendern von Intro, Menü, Spiel und Ergebnis. +Kein Fenster, kein Ton nötig. ## Eingefrorener Altstand -`old_version/` enthält die Dateien vor dem Umbau – ebenfalls in `.gitignore`, -also **nicht** versioniert. Details: `old_version/FROZEN.md`. +`old_version/` enthält die Dateien vor dem Umbau – in `.gitignore`, also +**nicht** versioniert. Details: `old_version/FROZEN.md`. ## Nächste Schritte 1. Namenseingabe direkt am Controller (Buchstabe für Buchstabe, alternativ - Tastatur) – ersetzt QR-Scanner und `last_user.txt`. -2. Mehrere Durchgänge ohne Neustart des Programms (zurück zur Namenseingabe). -3. Entscheiden, ob und wohin die Punkte gesendet werden. + Tastatur) – falls wieder eine Personalisierung gewünscht ist. +2. Entscheiden, ob und wohin Ergebnisse gesendet werden (Punkteliste ist + derzeit bewusst nicht vorhanden). diff --git a/docs/ANALYSE.md b/docs/ANALYSE.md index 13226f1..7ba7cbb 100644 --- a/docs/ANALYSE.md +++ b/docs/ANALYSE.md @@ -82,3 +82,15 @@ qr_scan.py ──► last_user.txt ──► subprocess: python loader_game. 3. Entscheiden, ob der Punkte-Upload wieder aktiv genutzt wird – und wohin. 4. `images/*.png` haben ein defektes sRGB-Profil (libpng-Warnung beim Laden, rein kosmetisch). + +## 6. Änderungen nach dem ersten Test (v1.1) + +Rückmeldungen aus dem ersten Durchlauf auf dem MacBook und was daraus wurde: + +| Rückmeldung | Umsetzung | +| --- | --- | +| „läuft viel zu schnell" | Stufen **Easy / Normal / Hard** (`difficulty.py`), Hard entspricht exakt dem Altstand-Tempo (720/240 px pro Sekunde). Auswahl im Startmenü mit `←`/`→` und A/Space; das Tempo steht jetzt an einer Stelle. | +| „beim Tippen plötzlich andere Textur" | Der Koffer merkt sich in `Case.texture` die Orientierung, in der er eingelaufen ist. Gedreht wird nur die Grafik (90°), nie gegen eine andere Bilddatei getauscht. | +| „Spieler klingt doof" | Name und Punkteliste komplett entfernt (`storage.py`, `net.py`, `--name` gelöscht). Angezeigt werden nur noch Stufe und Beladung in Prozent. | +| „soll nicht enden, sondern fragen: nochmals?" | Neue Phasenkette in `main.py`: Tutorial → Menü → Ampel → Runde → Ergebnis → Menü → … Beendet wird nur mit `Esc`/B. `Game.handle()` liefert dafür `HandleResult.CONTINUE / NEW_ROUND / QUIT`. | +| „lädst du von einer Map/CSV?" | Ja: `case_sequences.txt` im Projektwurzelverzeichnis, 6 Sequenzen als `breite,höhe,rotiert`. Pro Runde wird zufällig eine Sequenz gezogen. | diff --git a/loader_game/__init__.py b/loader_game/__init__.py index 1ef3ff5..2eb14d3 100644 --- a/loader_game/__init__.py +++ b/loader_game/__init__.py @@ -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" diff --git a/loader_game/case.py b/loader_game/case.py index 7b4c519..8b04ca1 100644 --- a/loader_game/case.py +++ b/loader_game/case.py @@ -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: diff --git a/loader_game/config.py b/loader_game/config.py index da4ce0e..339b1c9 100644 --- a/loader_game/config.py +++ b/loader_game/config.py @@ -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" diff --git a/loader_game/difficulty.py b/loader_game/difficulty.py new file mode 100644 index 0000000..4907b4c --- /dev/null +++ b/loader_game/difficulty.py @@ -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 diff --git a/loader_game/game.py b/loader_game/game.py index 5de59be..12adc0c 100644 --- a/loader_game/game.py +++ b/loader_game/game.py @@ -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 diff --git a/loader_game/intro.py b/loader_game/intro.py index 6a62e35..95c54cd 100644 --- a/loader_game/intro.py +++ b/loader_game/intro.py @@ -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": diff --git a/loader_game/menu.py b/loader_game/menu.py new file mode 100644 index 0000000..ff0efc4 --- /dev/null +++ b/loader_game/menu.py @@ -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)) diff --git a/loader_game/net.py b/loader_game/net.py deleted file mode 100644 index cd3f692..0000000 --- a/loader_game/net.py +++ /dev/null @@ -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." diff --git a/loader_game/scene.py b/loader_game/scene.py index 86d23fd..48faeec 100644 --- a/loader_game/scene.py +++ b/loader_game/scene.py @@ -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), - ) diff --git a/loader_game/storage.py b/loader_game/storage.py deleted file mode 100644 index bc1a2c8..0000000 --- a/loader_game/storage.py +++ /dev/null @@ -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 diff --git a/main.py b/main.py index 99e4d79..433a70c 100755 --- a/main.py +++ b/main.py @@ -1,30 +1,43 @@ #!/usr/bin/env python3 """Einstiegspunkt des Habegger Loader Games. +Ablauf: Tutorialbild (nur beim Programmstart) → Stufenauswahl → Ampel-Start → +Runde → Ergebnis ("Nochmals?") → Stufenauswahl → … + +Beendet wird ausschliesslich mit ``Esc`` (bzw. Controller-Taste B). + Beispiele:: - ./run.sh # Fenster, letzter Spielername + ./run.sh # Fenster ./run.sh --fullscreen # Vollbild (Messe-/Eventbetrieb) - ./run.sh --name "Anna" # Name direkt setzen ./run.sh --smoke 10 # Selbsttest ohne Eingabegeräte """ from __future__ import annotations import argparse +import enum import random import sys import pygame -from loader_game import config, storage +from loader_game import config from loader_game.assets import Assets -from loader_game.game import Game, State +from loader_game.difficulty import DEFAULT_DIFFICULTY, Difficulty +from loader_game.game import Game, HandleResult, State from loader_game.input_map import Action, actions_from_event, controller_hint_lines from loader_game.intro import Intro +from loader_game.menu import DifficultyMenu from loader_game.sequences import load_sequences +class Phase(enum.Enum): + INTRO = "intro" # Tutorial und/oder Ampel-Start + MENU = "menu" # Stufenauswahl + PLAY = "play" # laufende Runde + + def parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description="Habegger Loader Game") parser.add_argument( @@ -32,11 +45,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: action="store_true", help="Vollbild starten (Standard: Fenster; F11 schaltet jederzeit um)", ) - parser.add_argument( - "--name", - default=None, - help="Spielername (sonst der zuletzt gespeicherte aus runtime/)", - ) parser.add_argument( "--seed", type=int, @@ -48,8 +56,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: type=float, default=None, metavar="SEKUNDEN", - help="Selbsttest: bestätigt das Intro automatisch, spielt ohne Eingaben " - "und beendet sich nach N Sekunden", + help="Selbsttest: spielt ohne Eingaben und beendet sich nach N Sekunden", ) return parser.parse_args(argv) @@ -98,15 +105,72 @@ def main(argv: list[str] | None = None) -> int: for warning in warnings: print("Sequenz-Warnung:", warning, file=sys.stderr) - name = (args.name or storage.load_last_player() or config.DEFAULT_PLAYER_NAME).strip() - name = name[: config.MAX_PLAYER_NAME_LENGTH] or config.DEFAULT_PLAYER_NAME - storage.save_last_player(name) - rng = random.Random(args.seed) - intro: Intro | None = Intro(assets) - game: Game | None = None - clock = pygame.time.Clock() + difficulty: Difficulty = DEFAULT_DIFFICULTY + phase = Phase.INTRO + after_intro = Phase.MENU # nach dem Tutorial kommt die Stufenauswahl + intro: Intro | None = Intro(assets, show_instruction=True) + menu: DifficultyMenu | None = None + game: Game | None = None + + def apply_action(action: Action) -> bool: + """Verarbeitet eine Aktion in der aktuellen Phase. + + Rückgabe ``False`` = Programm beenden. + """ + nonlocal phase, intro, menu, game, difficulty, after_intro + + if action is Action.BACK: + return False + + if phase is Phase.INTRO and intro is not None: + intro.handle(action) + return True + + if phase is Phase.MENU and menu is not None: + selected = menu.handle(action) + if selected is not None: + difficulty = selected + intro = Intro(assets, show_instruction=False) + after_intro = Phase.PLAY + phase = Phase.INTRO + return True + + if phase is Phase.PLAY and game is not None: + result = game.handle(action) + if result is HandleResult.QUIT: + return False + if result is HandleResult.NEW_ROUND: + menu = DifficultyMenu(assets, selected=difficulty) + phase = Phase.MENU + return True + + def advance(dt: float) -> None: + """Weiterrechnen und Phasenwechsel ohne Eingabe.""" + nonlocal phase, intro, menu, game + + if phase is Phase.INTRO and intro is not None: + if intro.update(dt): + intro = None + if after_intro is Phase.PLAY: + game = Game(assets, sequences, difficulty=difficulty, rng=rng) + else: + menu = DifficultyMenu(assets, selected=difficulty) + phase = after_intro + elif phase is Phase.PLAY and game is not None: + game.update(dt) + + def draw() -> None: + if phase is Phase.INTRO and intro is not None: + intro.draw(screen) + elif phase is Phase.MENU and menu is not None: + menu.draw(screen) + elif phase is Phase.PLAY and game is not None: + game.draw(screen) + pygame.display.flip() + + clock = pygame.time.Clock() running = True smoke_remaining = args.smoke smoke_timer = 0.0 @@ -114,6 +178,7 @@ def main(argv: list[str] | None = None) -> int: while running: dt = min(clock.tick(config.FPS) / 1000.0, 0.1) + # ---------------------------------------------------------- Eingabe for event in pygame.event.get(): if event.type == pygame.QUIT: running = False @@ -122,47 +187,39 @@ def main(argv: list[str] | None = None) -> int: pygame.display.toggle_fullscreen() continue for action in actions_from_event(event): - if intro is not None: - intro.handle(action) - if action is Action.BACK: - running = False - elif game is not None: - running = game.handle(action) + if not apply_action(action): + running = False + break if not running: break - if intro is not None: - if intro.update(dt): - intro = None - game = Game(assets, sequences, name=name, rng=rng) - elif smoke_remaining is not None and intro.phase == "instruction": - intro.handle(Action.PRIMARY) - elif game is not None: - game.update(dt) + advance(dt) + # Selbsttest: ohne Eingabegeräte mitspielen if smoke_remaining is not None: smoke_remaining -= dt if smoke_remaining <= 0: running = False - elif game is not None and intro is None: + else: smoke_timer -= dt if smoke_timer <= 0: - smoke_timer = 0.25 - action = _demo_action(game, rng) - if action is not None: - running = game.handle(action) + smoke_timer = 0.3 + if phase is Phase.PLAY and game is not None: + demo = _demo_action(game, rng) + if demo is not None and not apply_action(demo): + running = False + elif not apply_action(Action.PRIMARY): + running = False - if intro is not None: - intro.draw(screen) - elif game is not None: - game.draw(screen) - pygame.display.flip() + draw() if game is not None: - percent = game.percent if game.state is State.GAME_OVER else game.trailer.load_percent() + percent = ( + game.percent if game.state is State.FINISHED else game.trailer.load_percent() + ) print( - f"Spiel beendet: Zustand={game.state.value}, " - f"Beladung={percent:.0f}%, Koffer im Auflieger={len(game.trailer.stack)}" + f"Spiel beendet: Stufe={difficulty.label}, Beladung={percent:.0f}%, " + f"Koffer im Auflieger={len(game.trailer.stack)}" ) pygame.quit() diff --git a/tests/test_logic.py b/tests/test_logic.py index d81a02a..90a6488 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -9,17 +9,17 @@ from __future__ import annotations import os import random -import tempfile import unittest -from pathlib import Path os.environ.setdefault("SDL_VIDEODRIVER", "dummy") os.environ.setdefault("SDL_AUDIODRIVER", "dummy") -from loader_game import config, storage +from loader_game import config from loader_game.case import Case -from loader_game.game import Game, State +from loader_game.difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, by_key +from loader_game.game import Game, HandleResult, State from loader_game.input_map import Action +from loader_game.menu import DifficultyMenu from loader_game.sequences import CaseSpec, load_sequences, parse_sequences from loader_game.trailer import Trailer @@ -78,6 +78,25 @@ class LayoutTests(unittest.TestCase): self.assertEqual(config.UNIT_LENGTH, config.UNIT_HEIGHT) +class DifficultyTests(unittest.TestCase): + def test_three_levels_in_order(self) -> None: + self.assertEqual([d.key for d in DIFFICULTIES], ["easy", "normal", "hard"]) + self.assertEqual([d.label for d in DIFFICULTIES], ["Easy", "Normal", "Hard"]) + + def test_hard_matches_old_game_speed(self) -> None: + hard = by_key("hard") + self.assertEqual((hard.speed_fast, hard.speed_slow), (720.0, 240.0)) + + def test_speeds_increase_with_difficulty(self) -> None: + fast = [d.speed_fast for d in DIFFICULTIES] + slow = [d.speed_slow for d in DIFFICULTIES] + self.assertEqual(fast, sorted(fast)) + self.assertEqual(slow, sorted(slow)) + + def test_unknown_key_falls_back_to_default(self) -> None: + self.assertIs(by_key("gibtsnicht"), DEFAULT_DIFFICULTY) + + class CaseTests(unittest.TestCase): def make_case(self, units_w: int = 2, units_h: int = 1, x: float | None = None) -> Case: return Case( @@ -100,6 +119,13 @@ class CaseTests(unittest.TestCase): slow_step = before - case.x self.assertLess(slow_step, fast_step) + def test_difficulty_scales_speed(self) -> None: + easy = self.make_case() + hard = self.make_case() + easy.move(0.1, wall_x=0, difficulty=DIFFICULTIES[0]) + hard.move(0.1, wall_x=0, difficulty=DIFFICULTIES[2]) + self.assertGreater(easy.x, hard.x) # easy ist langsamer, bleibt weiter rechts + def test_allow_snap_only_near_wall(self) -> None: case = self.make_case() case.move(1 / 60, wall_x=0) @@ -116,14 +142,19 @@ class CaseTests(unittest.TestCase): case.move(1 / 60, wall_x=0) self.assertEqual(case.x, start) - def test_rotate_swaps_footprint_and_respects_trailer_height(self) -> None: - case = self.make_case(units_w=3, units_h=1) - self.assertTrue(case.fits_trailer_height()) - case.rotate_footprint() - self.assertEqual((case.units_w, case.units_h), (1, 3)) + def test_case_keeps_its_texture_when_rotated(self) -> None: + case = self.make_case(units_w=1, units_h=2) + self.assertEqual(case.texture, (1, 2)) + self.assertFalse(case.is_rotated) - wide = self.make_case(units_w=4, units_h=1) - self.assertFalse(wide.fits_trailer_height()) + case.rotate_footprint() + self.assertEqual((case.units_w, case.units_h), (2, 1)) + self.assertEqual(case.texture, (1, 2)) # Textur bleibt + self.assertTrue(case.is_rotated) + + def test_rotate_respects_trailer_height(self) -> None: + self.assertTrue(self.make_case(units_w=3, units_h=1).fits_trailer_height()) + self.assertFalse(self.make_case(units_w=4, units_h=1).fits_trailer_height()) class TrailerTests(unittest.TestCase): @@ -183,11 +214,10 @@ class TrailerTests(unittest.TestCase): # Ein flacher Koffer am Boden hat noch Platz darüber -> darf Plattform sein. ground = self.make_case(4, 1) self.trailer.stack_at_wall(ground) - self.assertEqual(ground.y, config.TRAILER_Y + config.TRAILER_HEIGHT - ground.height) self.assertIs(self.trailer.find_platform(self.make_case(1, 1)), ground) def test_no_platform_when_case_reaches_top(self) -> None: - # Ein 3 Einheiten hoher Koffer endet an der Oberkante -> kein Platz darüber. + # 3 Einheiten hoch = Oberkante des Aufliegers -> kein Platz darüber. ground = self.make_case(2, 3) self.trailer.stack_at_wall(ground) self.assertEqual(ground.y, config.TRAILER_Y) @@ -223,10 +253,40 @@ class TrailerTests(unittest.TestCase): self.assertTrue(self.trailer.overflow()) +class MenuTests(unittest.TestCase): + def test_navigation_wraps_around(self) -> None: + menu = DifficultyMenu(None, selected=DIFFICULTIES[1]) + self.assertIs(menu.selected, DIFFICULTIES[1]) + + self.assertIsNone(menu.handle(Action.LEFT)) + self.assertIs(menu.selected, DIFFICULTIES[0]) + + menu.handle(Action.LEFT) # Umlauf ans Ende + self.assertIs(menu.selected, DIFFICULTIES[-1]) + + menu.handle(Action.RIGHT) # Umlauf an den Anfang + self.assertIs(menu.selected, DIFFICULTIES[0]) + + def test_confirm_returns_selection(self) -> None: + menu = DifficultyMenu(None, selected=DIFFICULTIES[2]) + self.assertIs(menu.handle(Action.PRIMARY), DIFFICULTIES[2]) + + def test_other_actions_do_not_select(self) -> None: + menu = DifficultyMenu(None) + self.assertIsNone(menu.handle(Action.UP)) + self.assertIsNone(menu.handle(Action.DOWN)) + self.assertIsNone(menu.handle(Action.BACK)) + + class GameTests(unittest.TestCase): - def make_game(self, sequence_length: int = 30) -> Game: + def make_game(self, sequence_length: int = 30, difficulty=None) -> Game: sequences = [[CaseSpec(2, 1)] * sequence_length] - return Game(None, sequences, name="Test", rng=random.Random(7), submit_scores=False) + return Game( + None, + sequences, + difficulty=difficulty or DIFFICULTIES[2], + rng=random.Random(7), + ) def update_until(self, game: Game, predicate, steps: int = 5000) -> bool: for _ in range(steps): @@ -253,23 +313,29 @@ class GameTests(unittest.TestCase): game.trailer.wall_x, config.TRAILER_X + 2 * config.UNIT_LENGTH ) - def test_rotation_turns_the_case(self) -> None: - game = self.make_game() + def test_rotation_keeps_texture(self) -> None: + game = Game( + None, + [[CaseSpec(1, 2)] * 5], + difficulty=DIFFICULTIES[2], + rng=random.Random(1), + ) case = game.current case.x = float(game.trailer.wall_x + config.SNAP_THRESHOLD + 1) case.allow_snap = True game.handle(Action.LEFT) self.update_until(game, lambda: game.animation is None) - self.assertEqual((case.units_w, case.units_h), (1, 2)) + self.assertEqual((case.units_w, case.units_h), (2, 1)) + self.assertEqual(case.texture, (1, 2)) + self.assertTrue(case.is_rotated) self.assertFalse(case.can_rotate) def test_rotation_ignored_for_wide_cases(self) -> None: game = Game( None, [[CaseSpec(4, 1)] * 5], - name="Test", + difficulty=DIFFICULTIES[2], rng=random.Random(1), - submit_scores=False, ) case = game.current case.allow_snap = True @@ -287,14 +353,18 @@ class GameTests(unittest.TestCase): self.assertFalse(game.trailer.stack[0].snapped) self.assertGreater(game.shake, 0) + def test_easy_is_slower_than_hard(self) -> None: + easy = self.make_game(difficulty=DIFFICULTIES[0]) + hard = self.make_game(difficulty=DIFFICULTIES[2]) + for _ in range(30): + easy.update(1 / 60) + hard.update(1 / 60) + self.assertGreater(easy.current.x, hard.current.x) + def test_game_ends_when_trailer_overflows(self) -> None: game = self.make_game(sequence_length=40) self.assertTrue( - self.update_until( - game, - lambda: game.state is State.GAME_OVER, - steps=20000, - ) + self.update_until(game, lambda: game.state is State.FINISHED, steps=20000) ) self.assertGreater(game.percent, 0) self.assertLessEqual(game.percent, 100) @@ -302,47 +372,102 @@ class GameTests(unittest.TestCase): def test_game_ends_when_sequence_is_exhausted(self) -> None: game = self.make_game(sequence_length=3) for _ in range(20000): - if game.state is State.GAME_OVER: + if game.state is State.FINISHED: break if game.current is not None and game.current.allow_snap: game.handle(Action.PRIMARY) game.update(1 / 60) - self.assertIs(State.GAME_OVER, game.state) + self.assertIs(State.FINISHED, game.state) self.assertEqual(len(game.trailer.stack), 3) - def test_back_quits_and_primary_confirms_game_over(self) -> None: + def test_back_quits(self) -> None: + game = self.make_game() + self.assertIs(game.handle(Action.BACK), HandleResult.QUIT) + + def test_primary_after_finish_starts_new_round(self) -> None: game = self.make_game(sequence_length=2) - self.assertTrue(game.handle(Action.PRIMARY)) - self.assertFalse(game.handle(Action.BACK)) + for _ in range(20000): + if game.state is State.FINISHED: + break + if game.current is not None and game.current.allow_snap: + game.handle(Action.PRIMARY) + game.update(1 / 60) + self.assertIs(game.state, State.FINISHED) + self.assertIs(game.handle(Action.PRIMARY), HandleResult.NEW_ROUND) - def test_game_over_lines_mention_player(self) -> None: - game = self.make_game(sequence_length=1) - game.percent = 80 - lines = game.game_over_lines() - self.assertIn("Test", lines[0]) - self.assertIn("80%", lines[0]) + def test_result_lines_show_difficulty_and_percent(self) -> None: + game = self.make_game(difficulty=DIFFICULTIES[2]) + game.percent = 82 + lines = game.result_lines() + self.assertIn("Hard", lines[0]) + self.assertIn("82%", lines[0]) + self.assertTrue(any("ESC" in line for line in lines)) -class StorageTests(unittest.TestCase): - def test_roundtrip_in_runtime_dir(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - os.environ["HAG_RUNTIME_DIR"] = tmp - try: - self.assertIsNone(storage.load_last_player()) - storage.save_last_player(" Anna ") - self.assertEqual(storage.load_last_player(), "Anna") - self.assertTrue((Path(tmp) / storage.LAST_PLAYER_FILE).exists()) - finally: - del os.environ["HAG_RUNTIME_DIR"] +class RenderTests(unittest.TestCase): + """Rendert je ein Bild von Intro, Menü und Spiel (ohne Fenster). - def test_empty_name_is_ignored(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - os.environ["HAG_RUNTIME_DIR"] = tmp - try: - storage.save_last_player(" ") - self.assertIsNone(storage.load_last_player()) - finally: - del os.environ["HAG_RUNTIME_DIR"] + Fängt fehlende config-Konstanten und Zeichenfehler ab, die reine + Logiktests nicht bemerken. + """ + + @classmethod + def setUpClass(cls) -> None: + import pygame + + from loader_game.assets import Assets + + pygame.mixer.pre_init(44100, -16, 2, 512) + pygame.init() + cls.surface = pygame.display.set_mode( + (config.SCREEN_WIDTH, config.SCREEN_HEIGHT) + ) + cls.assets = Assets() + cls.assets.load() + + @classmethod + def tearDownClass(cls) -> None: + import pygame + + pygame.quit() + + def make_game(self) -> Game: + return Game( + self.assets, + [[CaseSpec(3, 1), CaseSpec(1, 2), CaseSpec(2, 2)]], + difficulty=DIFFICULTIES[1], + rng=random.Random(3), + ) + + def test_intro_renders(self) -> None: + from loader_game.intro import Intro + + intro = Intro(self.assets, show_instruction=True) + intro.draw(self.surface) + intro.handle(Action.PRIMARY) + for _ in range(200): + intro.update(1 / 60) + intro.draw(self.surface) + + def test_menu_renders(self) -> None: + menu = DifficultyMenu(self.assets, selected=DIFFICULTIES[0]) + menu.handle(Action.RIGHT) + menu.draw(self.surface) + + def test_game_and_result_render(self) -> None: + game = self.make_game() + game.draw(self.surface) + game.percent = 61.5 + game.state = State.FINISHED + game.draw(self.surface) + + def test_rotated_case_draws_something(self) -> None: + from loader_game import scene + + case = Case(units_w=1, units_h=2, x=200.0, y=400.0) + case.rotate_footprint() + self.assertTrue(case.is_rotated) + scene.draw_case(self.surface, self.assets, case) if __name__ == "__main__":