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
+606 -335

No files matched your search

+2 -2
View File
@@ -6,8 +6,8 @@ old_version/
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
# Laufzeitdaten (Spielername, lokale Ergebnisse, Logs) # Lokale Logs
runtime/ # (keine Laufzeitdaten mehr)
*.log *.log
# Editor- und OS-Kram # Editor- und OS-Kram
+39 -44
View File
@@ -1,8 +1,8 @@
# Habegger Trailer Load # Habegger Trailer Load
Kofferlager-Spiel für Messe-/Eventbetrieb: Koffer laufen von rechts ein und 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 müssen im Auflieger möglichst dicht gestapelt werden. Angezeigt wird am Ende
Beladung in Prozent. die Stufe und die erreichte Beladung in Prozent.
Neu aufgesetzt für macOS (Apple Silicon) mit Controller-Unterstützung. Neu aufgesetzt für macOS (Apple Silicon) mit Controller-Unterstützung.
Die Analyse des Altstands und die Liste der behobenen Fehler stehen in 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`** Auf macOS lässt sich das Spiel auch per Doppelklick auf **`run.command`**
starten (beim ersten Mal ggf. Rechtsklick → Öffnen wegen Gatekeeper). starten (beim ersten Mal ggf. Rechtsklick → Öffnen wegen Gatekeeper).
Optionen:
| Option | Bedeutung | | Option | Bedeutung |
| --- | --- | | --- | --- |
| `--fullscreen` | Vollbild starten (F11 schaltet jederzeit um) | | `--fullscreen` | Vollbild starten (F11 schaltet jederzeit um) |
| `--name "Anna"` | Spielername setzen (sonst der zuletzt gespeicherte) |
| `--seed 42` | feste Zufallsfolge, reproduzierbare Koffer-Sequenzen | | `--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 ## Steuerung
| Aktion | Tastatur | Controller (Xbox-Layout) | | 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 | | Koffer drehen (wenn er passt) | `←` | X / D-Pad links |
| Auf einen Koffer obendrauf setzen | `↑` | Y / D-Pad hoch | | Auf einen Koffer obendrauf setzen | `↑` | Y / D-Pad hoch |
| Spiel beenden | `Esc` | B | | Beenden | `Esc` | B |
| Vollbild umschalten | `F11` | – | | Vollbild umschalten | `F11` | – |
Stick und D-Pad werden beide ausgewertet, Controller-Hotplug funktioniert Ein gedrehter Koffer behält seine Grafik – er wird nur um 90° gedreht
(der Name des erkannten Controllers steht beim Start im Terminal). gezeichnet, nie durch eine andere Textur ersetzt.
## Projektstruktur ## Projektstruktur
``` ```
main.py Einstiegspunkt (Fenster, Hauptschleife, CLI) main.py Einstiegspunkt (Phasen: Intro → Menü → Runde)
run.sh / run.command Start inkl. venv + Installation run.sh / run.command Start inkl. venv + Installation
requirements.txt pygame requirements.txt pygame
case_sequences.txt Koffersequenzen (Datenbestand) case_sequences.txt Koffersequenzen (Datenbestand)
loader_game/ 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 assets.py Bilder/Sounds/Fonts laden und cachen
sequences.py Einlesen und Validieren der Sequenzdatei 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) trailer.py Stapelregeln im Auflieger (reine Logik)
input_map.py Tastatur/Controller → Action input_map.py Tastatur/Controller → Action
scene.py Zeichnen scene.py Zeichnen
menu.py Stufenauswahl
intro.py Tutorialbild, Ampel, Reissverschluss intro.py Tutorialbild, Ampel, Reissverschluss
game.py Zustandsmaschine des Spiels game.py Zustandsmaschine einer Runde
storage.py Laufzeitdaten (letzter Spielername) tests/test_logic.py 48 Tests (ohne Fenster)
net.py optionaler Punkte-Upload
tests/test_logic.py 35 Tests (ohne Fenster)
docs/ANALYSE.md Analyse Altstand + Fehlerliste docs/ANALYSE.md Analyse Altstand + Fehlerliste
old_version/ eingefrorener Altstand (nicht versioniert) old_version/ eingefrorener Altstand (nicht versioniert)
runtime/ Laufzeitdaten (nicht versioniert)
``` ```
## Konfiguration ## Konfiguration
Alle Werte stehen in `loader_game/config.py` – Auflösung, Bänder, Auflieger, Alle Werte stehen in `loader_game/config.py` (Layout, Farben, Texte, Tempo der
Tempo, Farben, Texte. Gezeichnet wird immer in der virtuellen Auflösung Animationen) und `loader_game/difficulty.py` (Einlaufgeschwindigkeit der drei
1920×1080; `pygame.SCALED` skaliert automatisch auf die tatsächliche Stufen). Gezeichnet wird immer in der virtuellen Auflösung 1920×1080;
Fenstergrösse (Retina, 1440×900, 4K …). `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.
## Sequenzdatei ## Sequenzdatei
@@ -109,23 +108,19 @@ Ungültige Zeilen werden beim Start mit Meldung übersprungen.
.venv/bin/python -m unittest discover -s tests -v .venv/bin/python -m unittest discover -s tests -v
``` ```
Getestet werden Sequenz-Parsing, Layout-Invarianten, Kofferbewegung, Getestet werden Sequenz-Parsing, Layout-Invarianten, Kofferbewegung
Stapelregeln, komplette Spieldurchläufe (bis Spielende) und die (inkl. Stufen-Tempo), Stapelregeln, Menü-Navigation, komplette Spieldurchläufe
Laufzeitdaten. Kein Fenster, kein Ton nötig. bis zum Ergebnis sowie das Rendern von Intro, Menü, Spiel und Ergebnis.
Kein Fenster, kein Ton nötig.
## Laufzeitdaten
`runtime/` (in `.gitignore`): zuletzt verwendeter Spielername. Per
`HAG_RUNTIME_DIR` umlenkbar (nutzen die Tests).
## Eingefrorener Altstand ## Eingefrorener Altstand
`old_version/` enthält die Dateien vor dem Umbau – ebenfalls in `.gitignore`, `old_version/` enthält die Dateien vor dem Umbau – in `.gitignore`, also
also **nicht** versioniert. Details: `old_version/FROZEN.md`. **nicht** versioniert. Details: `old_version/FROZEN.md`.
## Nächste Schritte ## Nächste Schritte
1. Namenseingabe direkt am Controller (Buchstabe für Buchstabe, alternativ 1. Namenseingabe direkt am Controller (Buchstabe für Buchstabe, alternativ
Tastatur) – ersetzt QR-Scanner und `last_user.txt`. Tastatur) – falls wieder eine Personalisierung gewünscht ist.
2. Mehrere Durchgänge ohne Neustart des Programms (zurück zur Namenseingabe). 2. Entscheiden, ob und wohin Ergebnisse gesendet werden (Punkteliste ist
3. Entscheiden, ob und wohin die Punkte gesendet werden. derzeit bewusst nicht vorhanden).
+12
View File
@@ -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. 3. Entscheiden, ob der Punkte-Upload wieder aktiv genutzt wird – und wohin.
4. `images/*.png` haben ein defektes sRGB-Profil (libpng-Warnung beim Laden, 4. `images/*.png` haben ein defektes sRGB-Profil (libpng-Warnung beim Laden,
rein kosmetisch). 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. |
+6 -6
View File
@@ -2,17 +2,17 @@
Aufbau (jedes Modul hat genau eine Aufgabe): Aufbau (jedes Modul hat genau eine Aufgabe):
* :mod:`loader_game.config` – alle Masse, Farben, Tempi, Pfade * :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.assets` – Bilder, Sounds, Fonts laden und cachen
* :mod:`loader_game.sequences` – Einlesen von ``case_sequences.txt`` * :mod:`loader_game.sequences` – Einlesen von ``case_sequences.txt``
* :mod:`loader_game.case` – der Koffer (Geometrie und Bewegung) * :mod:`loader_game.case` – der Koffer (Geometrie, Textur, Bewegung)
* :mod:`loader_game.trailer` – Stapelregeln im Auflieger (reine Logik) * :mod:`loader_game.trailer` – Stapelregeln im Auflieger (reine Logik)
* :mod:`loader_game.input_map` – Tastatur/Controller → abstrakte Aktionen * :mod:`loader_game.input_map` – Tastatur/Controller → abstrakte Aktionen
* :mod:`loader_game.scene` – Zeichnen * :mod:`loader_game.scene` – Zeichnen
* :mod:`loader_game.menu` – Stufenauswahl
* :mod:`loader_game.intro` – Tutorialbild + Ampel + Reissverschluss * :mod:`loader_game.intro` – Tutorialbild + Ampel + Reissverschluss
* :mod:`loader_game.game` – Zustandsmaschine des Spiels * :mod:`loader_game.game` – Zustandsmaschine einer Runde
* :mod:`loader_game.storage` – Laufzeitdaten (letzter Spielername)
* :mod:`loader_game.net` – optionaler Punkte-Upload
""" """
__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 dataclasses import dataclass
from . import config from . import config
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
@dataclass @dataclass
@@ -15,9 +16,11 @@ class Case:
"""Ein Koffer im Auflieger. """Ein Koffer im Auflieger.
``units_w`` / ``units_h`` beschreiben die **aktuelle** Grundfläche in ``units_w`` / ``units_h`` beschreiben die **aktuelle** Grundfläche in
Kasten-Einheiten. Eine Drehung tauscht die beiden Werte – es gibt also Kasten-Einheiten. Eine Drehung tauscht die beiden Werte.
keinen zusätzlichen ``rotated``-Schalter, der mit den Massen kollidieren
könnte (das war im Altstand eine Fehlerquelle). ``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 units_w: int
@@ -25,11 +28,16 @@ class Case:
x: float x: float
y: float y: float
color: tuple[int, int, int] = config.CASE_COLORS[0] color: tuple[int, int, int] = config.CASE_COLORS[0]
texture: tuple[int, int] | None = None
placed: bool = False placed: bool = False
snapped: bool = False snapped: bool = False
allow_snap: bool = False allow_snap: bool = False
can_rotate: bool = True can_rotate: bool = True
def __post_init__(self) -> None:
if self.texture is None:
self.texture = (self.units_w, self.units_h)
# -- Geometrie ------------------------------------------------------ # -- Geometrie ------------------------------------------------------
@property @property
def width(self) -> float: def width(self) -> float:
@@ -47,16 +55,26 @@ class Case:
def bottom(self) -> float: def bottom(self) -> float:
return self.y + self.height 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 ------------------------------------------------------- # -- 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.""" """Bewegt den Koffer nach links, mit Abbremsen kurz vor der Barriere."""
if self.placed: if self.placed:
return return
distance = self.x - wall_x distance = self.x - wall_x
speed = ( speed = (
config.CASE_SPEED_FAST difficulty.speed_fast
if distance > config.FAST_TO_SLOW_DISTANCE if distance > config.FAST_TO_SLOW_DISTANCE
else config.CASE_SPEED_SLOW else difficulty.speed_slow
) )
self.x -= speed * dt self.x -= speed * dt
if self.x <= wall_x + config.SNAP_THRESHOLD: 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 1920x1080. Das Fenster wird per ``pygame.SCALED`` automatisch auf die
tatsächliche Fenstergrösse skaliert – dadurch sieht das Spiel auf einem tatsächliche Fenstergrösse skaliert – dadurch sieht das Spiel auf einem
MacBook (Retina, 1440x900, …) genauso aus wie auf einem 1080p-Monitor. MacBook (Retina, 1440x900, …) genauso aus wie auf einem 1080p-Monitor.
Die Spielgeschwindigkeit steht in :mod:`loader_game.difficulty`.
""" """
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path from pathlib import Path
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Pfade # Pfade
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
RUNTIME_DIR = BASE_DIR / "runtime"
IMAGES_DIR = BASE_DIR / "images" IMAGES_DIR = BASE_DIR / "images"
FONTS_DIR = BASE_DIR / "fonts" FONTS_DIR = BASE_DIR / "fonts"
@@ -66,10 +66,8 @@ MAX_UNITS_W = 4
MAX_UNITS_H = 3 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 FAST_TO_SLOW_DISTANCE = 120 # ab hier wird abgebremst
SNAP_THRESHOLD = 60 # ab hier darf eingerastet werden SNAP_THRESHOLD = 60 # ab hier darf eingerastet werden
PLATFORM_TOLERANCE = 5 # Toleranz für "bündig an der Barriere" 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_WHITE = (255, 255, 255)
COLOR_HINT_SNAP = (0, 255, 0) COLOR_HINT_SNAP = (0, 255, 0)
COLOR_HINT_STACK = (255, 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) COLOR_PROGRESS_TRACK = (50, 50, 50)
CASE_COLORS = ((200, 0, 0), (0, 200, 0), (0, 0, 200)) CASE_COLORS = ((200, 0, 0), (0, 200, 0), (0, 0, 200))
@@ -125,27 +126,27 @@ PROGRESS_BAR_RADIUS = 10
FONT_SIZE = 48 FONT_SIZE = 48
FONT_SIZE_SMALL = 34 FONT_SIZE_SMALL = 34
TITLE_TEXT = WINDOW_TITLE TITLE_TEXT = WINDOW_TITLE
SUBTITLE_TEMPLATE = "Stufe {difficulty}"
NEXT_LABEL = "Next:" NEXT_LABEL = "Next:"
NEXT_PREVIEW_COUNT = 3 NEXT_PREVIEW_COUNT = 3
NEXT_PREVIEW_SCALE = 0.6 # Vorschau-Koffer sind kleiner als im Auflieger NEXT_PREVIEW_SCALE = 0.6 # Vorschau-Koffer sind kleiner als im Auflieger
NEXT_PREVIEW_MARGIN = 20 NEXT_PREVIEW_MARGIN = 20
HINT_SNAP = "Snap bereit!" HINT_SNAP = "Snap bereit!"
HINT_STACK = "OnTop möglich!" 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" MENU_TITLE = "Stufe wählen"
MAX_PLAYER_NAME_LENGTH = 14 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" # Spielende
# export HAG_SCORE_API_KEY="…" # --------------------------------------------------------------------------
# (Schlüssel gehören in die Umgebung, nicht in den Quellcode.) RESULT_TEMPLATE = "Stufe {difficulty}: {percent}% Beladung"
SCORE_URL = os.environ.get("HAG_SCORE_URL", "") RESULT_HINT = "Nochmals? A / SPACE = neue Runde ESC = beenden"
SCORE_API_KEY = os.environ.get("HAG_SCORE_API_KEY", "")
SCORE_TIMEOUT = 5.0
+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 . import config, scene
from .case import Case from .case import Case
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
from .input_map import Action from .input_map import Action
from .net import submit_score
from .sequences import CaseSpec, FALLBACK_SEQUENCE from .sequences import CaseSpec, FALLBACK_SEQUENCE
from .trailer import Trailer from .trailer import Trailer
class State(enum.Enum): class State(enum.Enum):
PLAYING = "playing" 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 @dataclass
@@ -45,14 +53,12 @@ class Game:
assets, assets,
sequences, sequences,
*, *,
name: str = config.DEFAULT_PLAYER_NAME, difficulty: Difficulty = DEFAULT_DIFFICULTY,
rng: random.Random | None = None, rng: random.Random | None = None,
submit_scores: bool = True,
) -> None: ) -> None:
self.assets = assets self.assets = assets
self.rng = rng or random.Random() self.rng = rng or random.Random()
self.name = name self.difficulty = difficulty
self.submit_scores = submit_scores
self.state = State.PLAYING self.state = State.PLAYING
self.trailer = Trailer() self.trailer = Trailer()
@@ -64,8 +70,6 @@ class Game:
self.animation: Animation | None = None self.animation: Animation | None = None
self.shake = 0.0 self.shake = 0.0
self.percent = 0.0 self.percent = 0.0
self.status_line = ""
self.status_is_error = False
self._spawn() self._spawn()
@@ -98,20 +102,21 @@ class Game:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Eingabe # Eingabe
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def handle(self, action: Action) -> bool: def handle(self, action: Action) -> HandleResult:
"""Verarbeitet eine Aktion. Rückgabe ``False`` = Spiel beenden."""
if action is Action.BACK: if action is Action.BACK:
return False return HandleResult.QUIT
if self.state is State.GAME_OVER: if self.state is State.FINISHED:
return action is not Action.PRIMARY if action is Action.PRIMARY:
return HandleResult.NEW_ROUND
return HandleResult.CONTINUE
if self.animation is not None or self.current is None: if self.animation is not None or self.current is None:
return True return HandleResult.CONTINUE
case = self.current case = self.current
if case.placed or not case.allow_snap: if case.placed or not case.allow_snap:
return True return HandleResult.CONTINUE
if action is Action.PRIMARY: if action is Action.PRIMARY:
self._start_snap(case) self._start_snap(case)
@@ -119,7 +124,7 @@ class Game:
self._start_rotate(case) self._start_rotate(case)
elif action is Action.UP: elif action is Action.UP:
self._start_stack(case) self._start_stack(case)
return True return HandleResult.CONTINUE
def _start_snap(self, case: Case) -> None: def _start_snap(self, case: Case) -> None:
self._stop_roll() self._stop_roll()
@@ -179,7 +184,7 @@ class Game:
self._finish() self._finish()
return 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: if not case.placed and case.x <= self.trailer.wall_x:
self._stack_with_gap(case, sound="fail") self._stack_with_gap(case, sound="fail")
return return
@@ -258,25 +263,25 @@ class Game:
self._finish() self._finish()
def _finish(self) -> None: def _finish(self) -> None:
if self.state is State.GAME_OVER: if self.state is State.FINISHED:
return return
self.state = State.GAME_OVER self.state = State.FINISHED
self._stop_roll() self._stop_roll()
self.percent = self.trailer.load_percent() self.percent = self.trailer.load_percent()
if self.percent >= 99.9: if self.percent >= 99.9:
self.percent = 100.0 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 # Zeichnen
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def draw(self, surface) -> None: def draw(self, surface) -> None:
assets = self.assets 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) scene.draw_background(surface, assets)
offset = (0, 0) offset = (0, 0)
@@ -304,22 +309,16 @@ class Game:
surface, assets, self.trailer.find_platform(case) is not None surface, assets, self.trailer.find_platform(case) is not None
) )
if self.state is State.GAME_OVER: if self.state is State.FINISHED:
scene.draw_overlay_lines( scene.draw_overlay_lines(surface, assets, self.result_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,
)
def game_over_lines(self) -> list[str]: def result_lines(self) -> list[str]:
percent = int(self.percent) return [
if self.percent >= config.GAME_OVER_GOOD_PERCENT: config.RESULT_TEMPLATE.format(
first = config.GAME_OVER_GOOD.format(name=self.name, percent=percent) difficulty=self.difficulty.label, percent=int(self.percent)
else: ),
first = config.GAME_OVER_NORMAL.format(name=self.name, percent=percent) config.RESULT_HINT,
return [first, config.GAME_OVER_HINT] ]
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Hilfen # Hilfen
+3 -2
View File
@@ -15,9 +15,10 @@ class Intro:
Reissverschluss, der den Auflieger "öffnet". Reissverschluss, der den Auflieger "öffnet".
""" """
def __init__(self, assets) -> None: def __init__(self, assets, *, show_instruction: bool = True) -> None:
self.assets = assets 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.timer = 0.0
self.dot_index = 0 self.dot_index = 0
if self.phase == "countdown": 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 # 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( surface.fill(
config.COLOR_TOP_BAR, (0, 0, config.SCREEN_WIDTH, config.TOP_BAR_HEIGHT) config.COLOR_TOP_BAR, (0, 0, config.SCREEN_WIDTH, config.TOP_BAR_HEIGHT)
) )
font = assets.font font = assets.font
title = font.render(config.TITLE_TEXT, True, config.COLOR_WHITE) title = font.render(config.TITLE_TEXT, True, config.COLOR_WHITE)
surface.blit(title, (40, config.TOP_BAR_HEIGHT // 2 - title.get_height() // 2)) 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) _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( surface.fill(
config.COLOR_BOTTOM_BAR, 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 top = config.SCREEN_HEIGHT - config.BOTTOM_BAR_HEIGHT
label = (assets.font_small or assets.font).render(config.NEXT_LABEL, True, config.COLOR_WHITE) label = (assets.font_small or assets.font).render(config.NEXT_LABEL, True, config.COLOR_WHITE)
surface.blit(label, (40, top + 12)) 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: 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) x, y = int(case.x), int(case.y)
width, height = int(case.width), int(case.height) 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: if image is None:
pygame.draw.rect(surface, case.color, (x, y, width, height)) pygame.draw.rect(surface, case.color, (x, y, width, height))
return return
if case.is_rotated:
image = pygame.transform.rotate(image, -90)
if angle: if angle:
rotated = pygame.transform.rotate(image, angle) rotated = pygame.transform.rotate(image, angle)
surface.blit(rotated, rotated.get_rect(bottomleft=(x, y + height))) 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( def draw_overlay_lines(
surface: pygame.Surface, surface: pygame.Surface,
assets, assets,
lines: list[str], lines: list[str],
*,
status: str = "",
status_color: tuple[int, int, int] = config.COLOR_ERROR,
) -> None: ) -> None:
overlay = pygame.Surface((config.SCREEN_WIDTH, config.SCREEN_HEIGHT)) """Dunkler Schleier mit zentrierten Textzeilen (Ergebnis-Anzeige)."""
overlay.set_alpha(220) draw_dim(surface, 220)
overlay.fill((0, 0, 0))
surface.blit(overlay, (0, 0))
center_y = config.SCREEN_HEIGHT // 2 center_y = config.SCREEN_HEIGHT // 2
line_gap = 16 line_gap = 16
total_height = 0
rendered: list[pygame.Surface] = [] rendered: list[pygame.Surface] = []
total_height = 0
for line in lines: for line in lines:
image = assets.font.render(line, True, config.COLOR_WHITE) image = assets.font.render(line, True, config.COLOR_WHITE)
rendered.append(image) rendered.append(image)
@@ -196,10 +225,3 @@ def draw_overlay_lines(
for image in rendered: for image in rendered:
surface.blit(image, (config.SCREEN_WIDTH // 2 - image.get_width() // 2, y)) surface.blit(image, (config.SCREEN_WIDTH // 2 - image.get_width() // 2, y))
y += image.get_height() + line_gap 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
+101 -44
View File
@@ -1,30 +1,43 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Einstiegspunkt des Habegger Loader Games. """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:: Beispiele::
./run.sh # Fenster, letzter Spielername ./run.sh # Fenster
./run.sh --fullscreen # Vollbild (Messe-/Eventbetrieb) ./run.sh --fullscreen # Vollbild (Messe-/Eventbetrieb)
./run.sh --name "Anna" # Name direkt setzen
./run.sh --smoke 10 # Selbsttest ohne Eingabegeräte ./run.sh --smoke 10 # Selbsttest ohne Eingabegeräte
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import enum
import random import random
import sys import sys
import pygame import pygame
from loader_game import config, storage from loader_game import config
from loader_game.assets import Assets 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.input_map import Action, actions_from_event, controller_hint_lines
from loader_game.intro import Intro from loader_game.intro import Intro
from loader_game.menu import DifficultyMenu
from loader_game.sequences import load_sequences 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: def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Habegger Loader Game") parser = argparse.ArgumentParser(description="Habegger Loader Game")
parser.add_argument( parser.add_argument(
@@ -32,11 +45,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
action="store_true", action="store_true",
help="Vollbild starten (Standard: Fenster; F11 schaltet jederzeit um)", 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( parser.add_argument(
"--seed", "--seed",
type=int, type=int,
@@ -48,8 +56,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
type=float, type=float,
default=None, default=None,
metavar="SEKUNDEN", metavar="SEKUNDEN",
help="Selbsttest: bestätigt das Intro automatisch, spielt ohne Eingaben " help="Selbsttest: spielt ohne Eingaben und beendet sich nach N Sekunden",
"und beendet sich nach N Sekunden",
) )
return parser.parse_args(argv) return parser.parse_args(argv)
@@ -98,15 +105,72 @@ def main(argv: list[str] | None = None) -> int:
for warning in warnings: for warning in warnings:
print("Sequenz-Warnung:", warning, file=sys.stderr) 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) rng = random.Random(args.seed)
intro: Intro | None = Intro(assets) difficulty: Difficulty = DEFAULT_DIFFICULTY
game: Game | None = None
clock = pygame.time.Clock()
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 running = True
smoke_remaining = args.smoke smoke_remaining = args.smoke
smoke_timer = 0.0 smoke_timer = 0.0
@@ -114,6 +178,7 @@ def main(argv: list[str] | None = None) -> int:
while running: while running:
dt = min(clock.tick(config.FPS) / 1000.0, 0.1) dt = min(clock.tick(config.FPS) / 1000.0, 0.1)
# ---------------------------------------------------------- Eingabe
for event in pygame.event.get(): for event in pygame.event.get():
if event.type == pygame.QUIT: if event.type == pygame.QUIT:
running = False running = False
@@ -122,47 +187,39 @@ def main(argv: list[str] | None = None) -> int:
pygame.display.toggle_fullscreen() pygame.display.toggle_fullscreen()
continue continue
for action in actions_from_event(event): for action in actions_from_event(event):
if intro is not None: if not apply_action(action):
intro.handle(action)
if action is Action.BACK:
running = False running = False
elif game is not None: break
running = game.handle(action)
if not running: if not running:
break break
if intro is not None: advance(dt)
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)
# Selbsttest: ohne Eingabegeräte mitspielen
if smoke_remaining is not None: if smoke_remaining is not None:
smoke_remaining -= dt smoke_remaining -= dt
if smoke_remaining <= 0: if smoke_remaining <= 0:
running = False running = False
elif game is not None and intro is None: else:
smoke_timer -= dt smoke_timer -= dt
if smoke_timer <= 0: if smoke_timer <= 0:
smoke_timer = 0.25 smoke_timer = 0.3
action = _demo_action(game, rng) if phase is Phase.PLAY and game is not None:
if action is not None: demo = _demo_action(game, rng)
running = game.handle(action) 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: draw()
intro.draw(screen)
elif game is not None:
game.draw(screen)
pygame.display.flip()
if game is not None: 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( print(
f"Spiel beendet: Zustand={game.state.value}, " f"Spiel beendet: Stufe={difficulty.label}, Beladung={percent:.0f}%, "
f"Beladung={percent:.0f}%, Koffer im Auflieger={len(game.trailer.stack)}" f"Koffer im Auflieger={len(game.trailer.stack)}"
) )
pygame.quit() pygame.quit()
+180 -55
View File
@@ -9,17 +9,17 @@ from __future__ import annotations
import os import os
import random import random
import tempfile
import unittest import unittest
from pathlib import Path
os.environ.setdefault("SDL_VIDEODRIVER", "dummy") os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "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.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.input_map import Action
from loader_game.menu import DifficultyMenu
from loader_game.sequences import CaseSpec, load_sequences, parse_sequences from loader_game.sequences import CaseSpec, load_sequences, parse_sequences
from loader_game.trailer import Trailer from loader_game.trailer import Trailer
@@ -78,6 +78,25 @@ class LayoutTests(unittest.TestCase):
self.assertEqual(config.UNIT_LENGTH, config.UNIT_HEIGHT) 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): class CaseTests(unittest.TestCase):
def make_case(self, units_w: int = 2, units_h: int = 1, x: float | None = None) -> Case: def make_case(self, units_w: int = 2, units_h: int = 1, x: float | None = None) -> Case:
return Case( return Case(
@@ -100,6 +119,13 @@ class CaseTests(unittest.TestCase):
slow_step = before - case.x slow_step = before - case.x
self.assertLess(slow_step, fast_step) 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: def test_allow_snap_only_near_wall(self) -> None:
case = self.make_case() case = self.make_case()
case.move(1 / 60, wall_x=0) case.move(1 / 60, wall_x=0)
@@ -116,14 +142,19 @@ class CaseTests(unittest.TestCase):
case.move(1 / 60, wall_x=0) case.move(1 / 60, wall_x=0)
self.assertEqual(case.x, start) self.assertEqual(case.x, start)
def test_rotate_swaps_footprint_and_respects_trailer_height(self) -> None: def test_case_keeps_its_texture_when_rotated(self) -> None:
case = self.make_case(units_w=3, units_h=1) case = self.make_case(units_w=1, units_h=2)
self.assertTrue(case.fits_trailer_height()) self.assertEqual(case.texture, (1, 2))
case.rotate_footprint() self.assertFalse(case.is_rotated)
self.assertEqual((case.units_w, case.units_h), (1, 3))
wide = self.make_case(units_w=4, units_h=1) case.rotate_footprint()
self.assertFalse(wide.fits_trailer_height()) 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): 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. # Ein flacher Koffer am Boden hat noch Platz darüber -> darf Plattform sein.
ground = self.make_case(4, 1) ground = self.make_case(4, 1)
self.trailer.stack_at_wall(ground) 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) self.assertIs(self.trailer.find_platform(self.make_case(1, 1)), ground)
def test_no_platform_when_case_reaches_top(self) -> None: 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) ground = self.make_case(2, 3)
self.trailer.stack_at_wall(ground) self.trailer.stack_at_wall(ground)
self.assertEqual(ground.y, config.TRAILER_Y) self.assertEqual(ground.y, config.TRAILER_Y)
@@ -223,10 +253,40 @@ class TrailerTests(unittest.TestCase):
self.assertTrue(self.trailer.overflow()) 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): 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] 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: def update_until(self, game: Game, predicate, steps: int = 5000) -> bool:
for _ in range(steps): for _ in range(steps):
@@ -253,23 +313,29 @@ class GameTests(unittest.TestCase):
game.trailer.wall_x, config.TRAILER_X + 2 * config.UNIT_LENGTH game.trailer.wall_x, config.TRAILER_X + 2 * config.UNIT_LENGTH
) )
def test_rotation_turns_the_case(self) -> None: def test_rotation_keeps_texture(self) -> None:
game = self.make_game() game = Game(
None,
[[CaseSpec(1, 2)] * 5],
difficulty=DIFFICULTIES[2],
rng=random.Random(1),
)
case = game.current case = game.current
case.x = float(game.trailer.wall_x + config.SNAP_THRESHOLD + 1) case.x = float(game.trailer.wall_x + config.SNAP_THRESHOLD + 1)
case.allow_snap = True case.allow_snap = True
game.handle(Action.LEFT) game.handle(Action.LEFT)
self.update_until(game, lambda: game.animation is None) 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) self.assertFalse(case.can_rotate)
def test_rotation_ignored_for_wide_cases(self) -> None: def test_rotation_ignored_for_wide_cases(self) -> None:
game = Game( game = Game(
None, None,
[[CaseSpec(4, 1)] * 5], [[CaseSpec(4, 1)] * 5],
name="Test", difficulty=DIFFICULTIES[2],
rng=random.Random(1), rng=random.Random(1),
submit_scores=False,
) )
case = game.current case = game.current
case.allow_snap = True case.allow_snap = True
@@ -287,14 +353,18 @@ class GameTests(unittest.TestCase):
self.assertFalse(game.trailer.stack[0].snapped) self.assertFalse(game.trailer.stack[0].snapped)
self.assertGreater(game.shake, 0) 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: def test_game_ends_when_trailer_overflows(self) -> None:
game = self.make_game(sequence_length=40) game = self.make_game(sequence_length=40)
self.assertTrue( self.assertTrue(
self.update_until( self.update_until(game, lambda: game.state is State.FINISHED, steps=20000)
game,
lambda: game.state is State.GAME_OVER,
steps=20000,
)
) )
self.assertGreater(game.percent, 0) self.assertGreater(game.percent, 0)
self.assertLessEqual(game.percent, 100) self.assertLessEqual(game.percent, 100)
@@ -302,47 +372,102 @@ class GameTests(unittest.TestCase):
def test_game_ends_when_sequence_is_exhausted(self) -> None: def test_game_ends_when_sequence_is_exhausted(self) -> None:
game = self.make_game(sequence_length=3) game = self.make_game(sequence_length=3)
for _ in range(20000): for _ in range(20000):
if game.state is State.GAME_OVER: if game.state is State.FINISHED:
break break
if game.current is not None and game.current.allow_snap: if game.current is not None and game.current.allow_snap:
game.handle(Action.PRIMARY) game.handle(Action.PRIMARY)
game.update(1 / 60) game.update(1 / 60)
self.assertIs(State.GAME_OVER, game.state) self.assertIs(State.FINISHED, game.state)
self.assertEqual(len(game.trailer.stack), 3) 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) game = self.make_game(sequence_length=2)
self.assertTrue(game.handle(Action.PRIMARY)) for _ in range(20000):
self.assertFalse(game.handle(Action.BACK)) 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: def test_result_lines_show_difficulty_and_percent(self) -> None:
game = self.make_game(sequence_length=1) game = self.make_game(difficulty=DIFFICULTIES[2])
game.percent = 80 game.percent = 82
lines = game.game_over_lines() lines = game.result_lines()
self.assertIn("Test", lines[0]) self.assertIn("Hard", lines[0])
self.assertIn("80%", lines[0]) self.assertIn("82%", lines[0])
self.assertTrue(any("ESC" in line for line in lines))
class StorageTests(unittest.TestCase): class RenderTests(unittest.TestCase):
def test_roundtrip_in_runtime_dir(self) -> None: """Rendert je ein Bild von Intro, Menü und Spiel (ohne Fenster).
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"]
def test_empty_name_is_ignored(self) -> None: Fängt fehlende config-Konstanten und Zeichenfehler ab, die reine
with tempfile.TemporaryDirectory() as tmp: Logiktests nicht bemerken.
os.environ["HAG_RUNTIME_DIR"] = tmp """
try:
storage.save_last_player(" ") @classmethod
self.assertIsNone(storage.load_last_player()) def setUpClass(cls) -> None:
finally: import pygame
del os.environ["HAG_RUNTIME_DIR"]
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__": if __name__ == "__main__":