diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c95a368 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Eingefrorener Altstand (bewusst NICHT versioniert) +old_version/ + +# Virtuelle Umgebung und Python-Artefakte +.venv/ +__pycache__/ +*.py[cod] + +# Laufzeitdaten (Spielername, lokale Ergebnisse, Logs) +runtime/ +*.log + +# Editor- und OS-Kram +.idea/ +.vscode/ +.DS_Store diff --git a/INSTALL_README.txt b/INSTALL_README.txt deleted file mode 100644 index 71acb36..0000000 --- a/INSTALL_README.txt +++ /dev/null @@ -1,3 +0,0 @@ -python.exe -m pip install --upgrade pip - -pip install pillow pyzbar opencv-python numpy pygame pygetwindow requests pyautogui diff --git a/README.md b/README.md index 0653cbe..5ccb550 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,131 @@ -# Habegger_loader_game +# 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. + +Neu aufgesetzt für macOS (Apple Silicon) mit Controller-Unterstützung. +Die Analyse des Altstands und die Liste der behobenen Fehler stehen in +[`docs/ANALYSE.md`](docs/ANALYSE.md). + +## Voraussetzungen + +* Python 3.10 oder neuer (getestet: 3.12) +* macOS oder Linux – sonst nichts. `pygame` bringt SDL2 selbst mit, + Kamera-, Brew- oder Systembibliotheken werden **nicht** gebraucht. + +## Start + +```bash +./run.sh # Fenster +./run.sh --fullscreen # Vollbild (Eventbetrieb) +``` + +`run.sh` legt beim ersten Start `.venv/` an und installiert `requirements.txt` +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 | + +## Steuerung + +| Aktion | Tastatur | Controller (Xbox-Layout) | +| --- | --- | --- | +| 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 | +| Vollbild umschalten | `F11` | – | + +Stick und D-Pad werden beide ausgewertet, Controller-Hotplug funktioniert +(der Name des erkannten Controllers steht beim Start im Terminal). + +## Projektstruktur + +``` +main.py Einstiegspunkt (Fenster, Hauptschleife, CLI) +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 + assets.py Bilder/Sounds/Fonts laden und cachen + sequences.py Einlesen und Validieren der Sequenzdatei + case.py Koffer: Geometrie und Einlaufbewegung + trailer.py Stapelregeln im Auflieger (reine Logik) + input_map.py Tastatur/Controller → Action + scene.py Zeichnen + 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) +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. + +## Sequenzdatei + +`case_sequences.txt`: Sequenzen durch Leerzeilen getrennt, pro Zeile ein +Koffer als `breite,höhe,rotiert` in Einheiten von 100×100 Pixeln. +`rotiert=True` dreht den Koffer beim Einlaufen um 90°. + +``` +3,1,False +1,2,True # effektiv 2 Einheiten breit, 1 hoch +``` + +Ungültige Zeilen werden beim Start mit Meldung übersprungen. + +## Tests + +```bash +.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). + +## Eingefrorener Altstand + +`old_version/` enthält die Dateien vor dem Umbau – ebenfalls 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. diff --git a/docs/ANALYSE.md b/docs/ANALYSE.md new file mode 100644 index 0000000..13226f1 --- /dev/null +++ b/docs/ANALYSE.md @@ -0,0 +1,84 @@ +# Analyse Altstand (01.11.2026) + +Grundlage: `old_version/loader_game.py` (656 Zeilen), `old_version/qr_scan.py` +(188 Zeilen), `old_version/qr_scan_old.py` (160 Zeilen), +`old_version/case_sequences.txt` (6 Sequenzen). + +## 1. Wie der Altstand funktioniert hat + +``` +qr_scan.py ──► last_user.txt ──► subprocess: python loader_game.py + │ │ + └ Kamera + pyzbar + HTTP-API └ pygame-Spiel, ein Durchgang, dann sys.exit() +``` + +* **`qr_scan.py`**: öffnet die Webcam, liest QR-Codes, fragt eine externe API + (`hag.putschgi.ch/.../qr.php?action=verify_hash`) nach Gültigkeit und Name, + schreibt `mail`/`name` in `last_user.txt` und startet das Spiel als + Unterprozess. Danach wieder Scanner – so kam der nächste Spieler dran. +* **`loader_game.py`**: ein Durchgang. Ein Koffer läuft von rechts ein; der + Spieler kann ihn an die Barriere snappen (Space/A), drehen (←/X) oder auf + einen anderen Koffer stapeln (↑/Y). Kollidiert er ungebremst, wird er mit + einer Lücke eingereiht. Ist die Barriere hinter dem Heck, ist Schluss. + Punktzahl = belegte Fläche im Auflieger, wird per `curl` an + `submit_points.php` geschickt. +* **`case_sequences.txt`**: 6 Koffersequenzen, Zeilen `breite,höhe,rotiert`, + Sequenzen durch Leerzeilen getrennt. Das Spiel zieht sich zufällig eine. + +## 2. Gefundene Fehler und Risiken + +| # | Fund | Wirkung | Status im Neustand | +| --- | --- | --- | --- | +| 1 | **Secrets im Quellcode**: `API_SECRET = "ddlK…"` in `qr_scan.py`, `api_key=hag-trailer-8051` in `loader_game.py` | Zugangsdaten liegen in der Git-Historie | entfernt; Endpunkt + Schlüssel nur noch per `HAG_SCORE_URL` / `HAG_SCORE_API_KEY` aus der Umgebung → **alte Schlüssel rotieren** | +| 2 | `pygame.event.set_grab(True)` | Maus/Tastatur werden gefangen; auf macOS unangenehm, Esc kommt nicht mehr durch | entfernt | +| 3 | Feste 1920×1080-Vollbildannahme | MacBook (Retina/1440×900) → Fenster grösser als der Bildschirm | virtuelle Auflösung + `pygame.SCALED`, Fenster/`--fullscreen`, F11 | +| 4 | Windows-Annahmen: `subprocess.run(["python", …])`, `pygetwindow`/`pyautogui`, `python.exe` in der Install-Doku | läuft so nicht auf macOS | kein Subprozess mehr, Scanner gestrichen | +| 5 | **Doppelte Drehlogik**: `Case.rotated` **und** getauschte Masse; `draw()` skaliert bei `rotated` auf `(height, width)` und dreht zusätzlich um 90° | verzerrte/falsch gedrehte Koffer, schwer nachvollziehbar | nur noch `units_w`/`units_h`; Grafik wird einmalig passend erzeugt | +| 6 | Zufällige Zusatzdrehung in `Case.__init__` (`random.choice([True, False])`) | die „Next"-Vorschau zeigt etwas anderes als der Koffer, der wirklich kommt | Zufallsdrehung entfernt, Vorschau stimmt jetzt | +| 7 | `can_place_on_top()` und `animate_place_on_top()` prüfen **unterschiedliche** Regeln (Barriere-Ausrichtung fehlt in der Animation) | „OnTop möglich!" wird angekündigt, dann landet der Koffer woanders oder gar nicht | eine einzige Regel: `Trailer.find_platform()` | +| 8 | Zwei verschiedene Fehlschlag-Pfade (`fail_snap` einmal mit `sound_place`, einmal mit `sound_fail`) | inkonsistentes Feedback, verwirrende Punktevergabe | ein Pfad (`_stack_with_gap`), definierter Sound + Shake | +| 9 | Blockierende Animationsschleifen (`for` + `pygame.time.delay` mitten im Event-Loop) | Events stauen sich, keine `dt`-Basis, Ruckler; 120-fps-Ziel war so nicht erreichbar | Animationen laufen über `elapsed/duration` im Update, 60 fps | +| 10 | `Case.move()` startet den Roll-Sound und kehrt zurück; Sound hängt an `Channel(1)` | erster Frame ohne Bewegung; Kanal-Kollisionen mit anderen Sounds | Roll-Sound beim Spawn, Stopp beim Platzieren, eigener Kanal | +| 11 | Blankes `except:` an 6 Stellen (Bilder, Sequenzen, Punkte-Upload) | Fehler bleiben unsichtbar, z. B. Tippfehler in `case_sequences.txt` fallen still auf die Notfall-Sequenz zurück | gezielte Exceptions + Warnungen beim Start | +| 12 | `last_user.txt` wird mit `utf-8-sig` geschrieben und mit `utf-8` gelesen | BOM landet im Spielernamen; Datei liegt im Programmordner | `runtime/last_player.json`, Ordner ist in `.gitignore` | +| 13 | `calculate_load_percent()` summiert die Fläche **aller** Koffer, auch der aufgesetzten | Beladung ist „gefühlt" zu hoch; `min(100, …)` verdeckt das nur | bewusst beibehalten (Spielbalance), aber dokumentiert und getestet | +| 14 | Toter/doppelter Code: `global current_case` zweimal in `draw_game`, `can_use_on_top` in `spawn_case`, `transition_counter`, `state`/`PLAYING`, `entry_angle` (immer 0), `frame_color`, `game_started` | unnötige Verwirrung | entfernt | +| 15 | Kein Neustart möglich: Spiel endet mit `sys.exit()` | jeder Spieler braucht einen neuen Prozess (Aufgabe des Scanners) | Spiel ist eine Klasse; Aufrufer entscheidet (Neustart/Beenden) | +| 16 | `cap = cv2.VideoCapture(0)` in der Endlosschleife, `cv2.namedWindow()` pro Frame | Kamerahandle-Leak, Flackern | mit dem QR-Scanner entfallen (pyzbar/opencv nicht mehr nötig) | +| 17 | Keine `requirements.txt`, kein venv, Installation nur als Windows-Befehlskette | auf dem Mac nicht reproduzierbar | `requirements.txt` + `run.sh` (venv + Auto-Install) | +| 18 | Spieler kam nur über die API, ohne Server kein Spielstart | Abhängigkeit von einem fremden Server | Name kommt künftig vom Controller (Schritt 2) | + +## 3. Was der Neustand anders macht + +* **Struktur statt Monolith**: `main.py` + Paket `loader_game/` mit je einem + Modul pro Aufgabe (siehe `loader_game/__init__.py`). +* **Logik ohne pygame**: `case.py`, `trailer.py`, `sequences.py` lassen sich + ohne Fenster testen (35 Tests). +* **Zeitbasiert**: alles rechnet in Sekunden (`dt`), nicht in Frames. +* **Eine Wahrheit für Regeln**: Stapelregeln leben ausschliesslich in + `Trailer`, Zeichnen ausschliesslich in `scene.py`. +* **Konfiguration an einer Stelle**: `config.py` (Masse, Tempi, Farben, Texte). +* **Eingabe abstrahiert**: `input_map.py` bildet Tastatur **und** Controller auf + `Action.PRIMARY/LEFT/RIGHT/UP/DOWN/BACK` ab – die Basis für die + Controller-Namenseingabe. +* **Nur noch eine Abhängigkeit**: `pygame` (kein numpy, kein opencv, kein + pyzbar, kein requests, kein pyautogui). + +## 4. Bewusst *nicht* geändert + +* Spielmechanik und Balance (Tempo 720/240 px/s entspricht den alten 6/2 px pro + Frame bei 120 fps; Snap-Schwelle, Lücken-Logik, Beladungsformel). +* `case_sequences.txt` (Datenbestand bleibt unverändert). +* Grafiken, Sounds, Fonts. +* Der Punkte-Upload existiert weiter, ist aber ohne konfigurierten Endpunkt + stumm geschaltet. + +## 5. Offene Punkte (nächste Schritte) + +1. **Controller-Namenseingabe**: Buchstabe für Buchstabe per D-Pad/Stick, + alternative Eingabe über die Tastatur. Ersetzt `last_user.txt`/QR-Scanner. +2. **Mehrere Durchgänge ohne Prozess-Neustart** (Rückkehr zur Namenseingabe + statt Beenden). +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). diff --git a/loader_game.py b/loader_game.py deleted file mode 100644 index c807276..0000000 --- a/loader_game.py +++ /dev/null @@ -1,656 +0,0 @@ -import pygame -import random -import sys -import os -import subprocess -import numpy as np -import time - - -SCREEN_WIDTH = 1920 -SCREEN_HEIGHT = 1080 -FPS = 120 - -TOP_BAR_HEIGHT = SCREEN_HEIGHT // 8 -GAME_AREA_HEIGHT = SCREEN_HEIGHT // 1.6 -BOTTOM_BAR_HEIGHT = SCREEN_HEIGHT // 4 - -TRAILER_WIDTH = int(SCREEN_WIDTH * 0.885416666667) -TRAILER_HEIGHT = int(SCREEN_HEIGHT * 0.2778) -TRAILER_X = (SCREEN_WIDTH - TRAILER_WIDTH) // 4 -TRAILER_Y = TOP_BAR_HEIGHT + 200 - -CASE_SPEED_SLOW = 2 -CASE_SPEED_FAST = 6 -CASE_COLORS = [(200, 0, 0), (0, 200, 0), (0, 0, 200)] -SNAP_THRESHOLD = 60 -FAST_TO_SLOW_DISTANCE = 120 - -UNIT_LENGTH = TRAILER_HEIGHT // 3 -UNIT_HEIGHT = TRAILER_HEIGHT // 3 - -pygame.init() -pygame.joystick.init() -if pygame.joystick.get_count() > 0: - joystick = pygame.joystick.Joystick(0) - joystick.init() - print(f"Controller erkannt: {joystick.get_name()}") -else: - print("Kein Controller gefunden.") -pygame.mixer.init() -sound_fail = pygame.mixer.Sound("fail.ogg") -sound_fail.set_volume(0.6) -sound_place = pygame.mixer.Sound("place.ogg") -sound_place.set_volume(0.6) -sound_roll = pygame.mixer.Sound("roll.ogg") -sound_roll.set_volume(0.3) -current_case_visible = True - -# Animationssteuerung -transition_fps = 60 -transition_counter = 0 -screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) - - - -def generate_beep(frequency=440, duration_ms=500, volume=0.5): - sample_rate = 44100 - n_samples = int(sample_rate * (duration_ms / 1000.0)) - t = np.linspace(0, duration_ms / 1000.0, n_samples, False) - wave = np.sin(2 * np.pi * frequency * t) * volume - audio = (wave * 32767).astype(np.int16) - return pygame.sndarray.make_sound(np.column_stack([audio, audio])) - - -sound_tut1 = generate_beep(440, 600) -sound_tut2 = generate_beep(440, 400) -sound_tuuut = generate_beep(600, 1000) -pygame.display.set_caption("Habegger Loader Game") -pygame.event.set_grab(True) -clock = pygame.time.Clock() - -font = pygame.font.SysFont(None, 48) - -PLAYING = 1 -state = PLAYING -shake_timer = 0 - -stacked_cases = [] -case_sequence = [] -case_index = 0 -current_case = None -collision_x = TRAILER_X - -def load_instruction_image(): - try: - image = pygame.image.load("images/tutorial.png") - return pygame.transform.scale(image, (SCREEN_WIDTH, SCREEN_HEIGHT)) - except: - return None - -instruction_image = load_instruction_image() - -def show_instruction_screen(): - if not instruction_image: - return - waiting = True - while waiting: - screen.blit(instruction_image, (0, 0)) - pygame.display.flip() - for event in pygame.event.get(): - if event.type == pygame.QUIT: - pygame.quit() - sys.exit() - elif (event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE) or \ - (event.type == pygame.JOYBUTTONDOWN and event.button == 0): - waiting = False - - -def generate_fill_sequence(): - global case_sequence - try: - with open("case_sequences.txt", "r") as f: - sets = f.read().strip().split("\n\n") - chosen = random.choice(sets).strip().split("\n") - sequence = [] - for line in chosen: - parts = line.strip().split(",") - if len(parts) == 3: - w, h, rot = int(parts[0]), int(parts[1]), parts[2].strip().lower() == 'true' - sequence.append({'w': w, 'h': h, 'rotated': rot}) - case_sequence = sequence - except: - case_sequence = [{'w': 2, 'h': 1, 'rotated': False}, {'w': 1, 'h': 2, 'rotated': False}] - - -def load_background(): - try: - image = pygame.image.load("images/background.png") - return pygame.transform.scale(image, (SCREEN_WIDTH, GAME_AREA_HEIGHT)) - except: - return None - - -background_image = load_background() - -case_images = {} - - -def load_case_images(): - for w in range(1, 5): - for h in range(1, 4): - key = f"case_w{w}_h{h}" - path = f"images/{key}.png" - if os.path.exists(path): - case_images[key] = pygame.image.load(path).convert_alpha() - - -load_case_images() - - -class Case: - def animate_snap(self): - pygame.mixer.Channel(1).stop() - global collision_x - # Snap-Animation: langsam einrasten an die Rückwand - target_x = collision_x - steps = 10 - delta = (self.x - target_x) / steps - for _ in range(steps): - self.x -= delta - draw_game() - pygame.display.flip() - pygame.time.delay(int(1000 / transition_fps)) - self.x = target_x - self.placed = True - self.snapped = True - collision_x = self.x + self.width - - def mark_occupied(self): - for base in reversed(stacked_cases): - height_ok = base.y - self.height >= TRAILER_Y - width_ok = self.width <= base.width - space_free = all(not ( - other.y + other.height == base.y and - other.x < base.x + base.width and - other.x + other.width > base.x - ) for other in stacked_cases) and not getattr(base, 'occupied', False) - if height_ok and width_ok and space_free: - base.occupied = True - break - - def can_place_on_top(self): - if self.placed: - return False - for base in reversed(stacked_cases): - if base.y <= TRAILER_Y: - continue - height_ok = base.y - self.height >= TRAILER_Y - width_ok = self.width <= base.width - aligned_with_barrier = abs(base.x + base.width - collision_x) < 5 - space_free = all(not ( - other.y + other.height == base.y and - other.x < base.x + base.width and - other.x + other.width > base.x - ) for other in stacked_cases) - if height_ok and width_ok and aligned_with_barrier and space_free: - return True - return False - - def __init__(self, size=None): - self.rotated = False - global case_index - if not case_sequence: - generate_fill_sequence() - if size is None: - size = case_sequence[case_index % len(case_sequence)] - case_index += 1 - self.length_units, self.height_units = size['w'], size['h'] - if size['rotated']: - self.length_units, self.height_units = self.height_units, self.length_units - elif self.height_units * UNIT_LENGTH <= TRAILER_WIDTH and self.length_units * UNIT_HEIGHT <= TRAILER_HEIGHT and random.choice( - [True, False]): - self.length_units, self.height_units = self.height_units, self.length_units - self.width = self.length_units * UNIT_LENGTH - self.height = self.height_units * UNIT_HEIGHT - self.x = SCREEN_WIDTH - self.spawn_y = TRAILER_Y + TRAILER_HEIGHT - self.height - self.y = self.spawn_y - self.entry_angle = 0 - - self.color = random.choice(CASE_COLORS) - self.placed = False - self.allow_snap = False - self.snapped = False - self.occupied = False - - def move(self): - if not self.placed and not pygame.mixer.Channel(1).get_busy(): - pygame.mixer.Channel(1).play(sound_roll, loops=-1) - return - - distance_to_obstacle = self.x - collision_x - speed = CASE_SPEED_FAST if distance_to_obstacle > FAST_TO_SLOW_DISTANCE else CASE_SPEED_SLOW - self.x -= speed - if self.x <= collision_x + SNAP_THRESHOLD: - self.allow_snap = True - - def collides_with_barrier(self): - return not self.snapped and self.x <= collision_x - - def draw(self, surface): - angle = getattr(self, 'entry_angle', 0) - if self.rotated: - key = f"case_w{self.height_units}_h{self.length_units}" - else: - key = f"case_w{self.length_units}_h{self.height_units}" - image = case_images.get(key) - if image: - if self.rotated: - image_to_draw = pygame.transform.scale(image, (self.height, self.width)) - else: - image_to_draw = pygame.transform.scale(image, (self.width, self.height)) - if self.rotated: - image_to_draw = pygame.transform.rotate(image_to_draw, 90) - if angle != 0: - image_to_draw = pygame.transform.rotate(image_to_draw, angle) - rect = image_to_draw.get_rect(center=(self.x + self.width // 2, self.y + self.height // 3)) - surface.blit(image_to_draw, rect) - else: - surface.blit(image_to_draw, (self.x, self.y)) - else: - pygame.draw.rect(surface, self.color, (self.x, self.y, self.width, self.height)) - - def animate_tip(self): - global current_case_visible - current_case_visible = False - key = f"case_w{self.length_units}_h{self.height_units}" - image = case_images.get(key) - if image: - original = pygame.transform.scale(image, (self.width, self.height)).convert_alpha() - else: - original = pygame.Surface((self.width, self.height), pygame.SRCALPHA) - pygame.draw.rect(original, self.color, (0, 0, self.width, self.height)) - steps = 10 - for step in range(steps + 1): - angle = step * (90 / steps) - rotated = pygame.transform.rotate(original, angle) - new_rect = rotated.get_rect(bottomleft=(self.x, self.y + self.height)) - draw_game() - screen.blit(rotated, new_rect) - pygame.display.flip() - pygame.time.delay(int(1000 / transition_fps)) - self.width, self.height = self.height, self.width - self.length_units, self.height_units = self.height_units, self.length_units - self.rotated = not getattr(self, 'rotated', False) - self.x = max(collision_x + SNAP_THRESHOLD // 2, self.x) - self.y = TRAILER_Y + TRAILER_HEIGHT - self.height - current_case_visible = True - - def animate_place_on_top(self): - pygame.mixer.Channel(1).stop() - global current_case_visible, collision_x - for base in reversed(stacked_cases): - height_ok = base.y - self.height >= TRAILER_Y - width_ok = self.width <= base.width - space_free = all(not ( - other.y + other.height == base.y and - other.x < base.x + base.width and - other.x + other.width > base.x - ) for other in stacked_cases) - if height_ok and width_ok and space_free: - start_y = self.y - target_y = base.y - self.height - steps = 10 - for step in range(steps): - self.y = start_y - ((start_y - target_y) * (step + 1) / steps) - draw_game() - pygame.display.flip() - pygame.time.delay(int(1000 / transition_fps)) - self.y = target_y - start_x = self.x - target_x = base.x + base.width - self.width - for step in range(steps): - self.x = start_x - ((start_x - target_x) * (step + 1) / steps) - draw_game() - pygame.display.flip() - pygame.time.delay(int(1000 / transition_fps)) - self.x = target_x - self.placed = True - self.snapped = True - self.mark_occupied() - collision_x = self.x + self.width - return True - current_case_visible = True - return False - - def snap_to_wall(self): - global collision_x - self.x = collision_x - self.placed = True - self.snapped = True - collision_x = self.x + self.width - - def fail_snap(self): - pygame.mixer.Channel(1).stop() - global collision_x - self.x = collision_x + SNAP_THRESHOLD - self.placed = True - collision_x = self.x + self.width - - -def draw_progress_bar(surface, percent): - bar_width = SCREEN_WIDTH * 0.25 - bar_height = 30 - x = SCREEN_WIDTH - bar_width - 140 - y = TOP_BAR_HEIGHT // 2 - bar_height // 2 - gap = 8 - segment_width = (bar_width - 4 * gap) / 5 - red = min(255, int(percent * 2.55)) - green = max(0, 255 - int(percent * 2.55)) - color = (red, green, 0) - for i in range(5): - segment_x = x + i * (segment_width + gap) - pygame.draw.rect(surface, (50, 50, 50), (segment_x, y, segment_width, bar_height), border_radius=10) - if percent >= (i + 1) * 20: - pygame.draw.rect(surface, color, (segment_x, y, segment_width, bar_height), border_radius=10) - text = font.render(f"{int(percent)}%", True, (255, 255, 255)) - surface.blit(text, (x + bar_width + 10, y)) - - -def draw_title(surface): - title = font.render("Habegger Trailer Load", True, (255, 255, 255)) - surface.blit(title, (40, TOP_BAR_HEIGHT // 2 - title.get_height() // 2)) - - -def calculate_load_percent(): - total_volume = TRAILER_WIDTH * TRAILER_HEIGHT - used_volume = sum([c.width * c.height for c in stacked_cases]) - return min(100, (used_volume / total_volume) * 100) - - -next_queue = [] - - -def draw_game(hide_trailer=False): - global current_case - global shake_timer - global transition_counter - - if transition_counter > 0: - transition_counter -= 1 - pygame.time.delay(int(1000 / FPS)) - global current_case - global shake_timer - offset_x = random.randint(-5, 5) if shake_timer > 0 else 0 - offset_y = random.randint(-5, 5) if shake_timer > 0 else 0 - if shake_timer > 0: - shake_timer -= 1 - screen.fill((0, 0, 0)) - pygame.draw.rect(screen, (0, 0, 0), (0, 0, SCREEN_WIDTH, TOP_BAR_HEIGHT)) - draw_title(screen) - percent = calculate_load_percent() - draw_progress_bar(screen, percent) - if background_image: - screen.blit(background_image, (0, TOP_BAR_HEIGHT)) - else: - pygame.draw.rect(screen, (0, 0, 0), (0, TOP_BAR_HEIGHT, SCREEN_WIDTH, GAME_AREA_HEIGHT)) - if not hide_trailer: - pygame.draw.rect(screen, (100, 100, 100), - (TRAILER_X + offset_x, TRAILER_Y + offset_y, TRAILER_WIDTH, TRAILER_HEIGHT)) - for c in stacked_cases: - c.draw(screen) - if current_case and current_case_visible: - current_case.draw(screen) - pygame.draw.line(screen, (255, 0, 0), (collision_x, TRAILER_Y), (collision_x, TRAILER_Y + TRAILER_HEIGHT), 3) - pygame.draw.rect(screen, (0, 0, 0), (0, TOP_BAR_HEIGHT + GAME_AREA_HEIGHT, SCREEN_WIDTH, BOTTOM_BAR_HEIGHT)) - if current_case and current_case.allow_snap: - on_top_possible = current_case.can_place_on_top() - if on_top_possible: - snap_text = font.render("OnTop möglich!", True, (255, 255, 0)) - else: - snap_text = font.render("Snap bereit!", True, (0, 255, 0)) - screen.blit(snap_text, (SCREEN_WIDTH - SCREEN_WIDTH // 3 - snap_text.get_width() // 2, - SCREEN_HEIGHT - BOTTOM_BAR_HEIGHT // 2 - snap_text.get_height() // 2)) - - # Vorschau "Next" - label = font.render("Next:", True, (255, 255, 255)) - screen.blit(label, (40, SCREEN_HEIGHT - BOTTOM_BAR_HEIGHT + 10)) - - preview_x = 40 - for i, case in enumerate(next_queue[:3]): - w_units = case['w'] - h_units = case['h'] - if case['rotated']: - w_units, h_units = h_units, w_units - key = f"case_w{w_units}_h{h_units}" - image = case_images.get(key) - w = w_units * UNIT_LENGTH - h = h_units * UNIT_HEIGHT - if image: - scaled = pygame.transform.scale(image, (w, h)) - screen.blit(scaled, (preview_x, SCREEN_HEIGHT - BOTTOM_BAR_HEIGHT + 40)) - else: - pygame.draw.rect(screen, CASE_COLORS[i % len(CASE_COLORS)], - (preview_x, SCREEN_HEIGHT - BOTTOM_BAR_HEIGHT + 40, w, h)) - preview_x += max(w, 80) + 20 - - -def spawn_case(): - global running - global can_use_on_top - can_use_on_top = True - global case_sequence, case_index, next_queue - if not case_sequence: - generate_fill_sequence() - - # Vorschau initial füllen - while len(next_queue) < 3: - index = (case_index + len(next_queue)) % len(case_sequence) - next_queue.append(case_sequence[index]) - - # aktuelles Case bestimmen - if case_index >= len(case_sequence): - return None - case_data = case_sequence[case_index % len(case_sequence)] - case_index += 1 - - # Vorschau nachrücken - if next_queue: - next_queue.pop(0) - while len(next_queue) < 3: - index = (case_index + len(next_queue)) % len(case_sequence) - next_queue.append(case_sequence[index]) - - return Case(size=case_data) - - -def show_start_sequence(): - center_x = TRAILER_X + TRAILER_WIDTH // 2 - center_y = TRAILER_Y + TRAILER_HEIGHT // 2 - colors = [(255, 0, 0), (255, 200, 0), (0, 255, 0)] - delays = [1000, 1000, 1000] # ms pro Punkt - sounds = [sound_tut1, sound_tut2, sound_tuuut] - radius = 90 - - for i in range(3): - draw_game(hide_trailer=True) - pygame.draw.circle(screen, colors[i], (center_x, center_y), radius) - sounds[i].play() - pygame.display.flip() - pygame.time.delay(delays[i]) - - # Reißverschluss-Effekt - zip_sound = pygame.mixer.Sound("zip.ogg") - zip_sound.set_volume(1) - zip_sound.play() - duration = 200 - steps = 60 - for step in range(steps + 1): - width = int(TRAILER_WIDTH * (step / steps)) - draw_game(hide_trailer=True) - pygame.draw.rect(screen, (100, 100, 100), (TRAILER_X, TRAILER_Y, width, TRAILER_HEIGHT)) - pygame.display.flip() - pygame.time.delay(duration // steps) - -pygame.time.delay(1000) -show_instruction_screen() -show_start_sequence() -current_case = spawn_case() - -def show_game_over(score): - try: - with open("last_user.txt", "r", encoding="utf-8") as f: - lines = f.readlines() - username = lines[1].strip() if len(lines) > 1 else "Spieler" - try: - result = subprocess.run( - ["curl", "-X", "POST", "https://hag.putschgi.ch/Yb54CO5Ypybx/api/submit_points.php", - "-d", f"name={username}", - "-d", f"score={int(score)}", - "-d", "api_key=hag-trailer-8051"], - capture_output=True, text=True - ) - if result.returncode != 0 or not "success" in result.stdout.lower(): - raise Exception("Serverfehler") - except: - error_text = font.render("Fehler: Punkte konnten nicht gesendet werden!", True, (255, 100, 100)) - screen.blit(error_text, (SCREEN_WIDTH // 2 - error_text.get_width() // 2, SCREEN_HEIGHT // 2 + 60)) - pygame.display.flip() - pygame.time.delay(2000) - except: - username = "Spieler" - pygame.mixer.stop() - if score >= 75: - message = f"Gratuliere {username}! Du hast {int(score)}% geladen! Drücke A oder SPACE um zu beenden." - else: - message = f"{username}, du hast {int(score)}% geladen. Drücke A oder SPACE um zu beenden." - overlay = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) - overlay.set_alpha(220) - overlay.fill((0, 0, 0)) - screen.blit(overlay, (0, 0)) - text = font.render(message, True, (255, 255, 255)) - screen.blit(text, (SCREEN_WIDTH // 2 - text.get_width() // 2, - SCREEN_HEIGHT // 2 - text.get_height() // 2)) - pygame.display.flip() - waiting = True - while waiting: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - pygame.quit() - sys.exit() - elif ( - (event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE) or - (event.type == pygame.JOYBUTTONDOWN and event.button == 0) - ): - pygame.quit() - sys.exit() -running = True -can_use_tilt = True -can_use_on_top = True -game_finished = False - - -while running: - if not game_finished and collision_x > TRAILER_X + TRAILER_WIDTH: - show_game_over(calculate_load_percent()) - game_finished = True - continue - if not game_finished and current_case is None and case_index >= len(case_sequence): - show_game_over(calculate_load_percent()) - game_finished = True - continue - clock.tick(FPS) - if transition_counter > 0: - transition_counter -= 1 - draw_game() - pygame.display.flip() - pygame.time.delay(int(1000 / FPS)) - continue - for event in pygame.event.get(): - if event.type == pygame.QUIT: - running = False - elif event.type == pygame.KEYDOWN or event.type == pygame.JOYBUTTONDOWN or event.type == pygame.JOYHATMOTION: - if state == PLAYING: - can_tip = current_case.length_units * UNIT_HEIGHT <= TRAILER_HEIGHT - if ( - (event.type == pygame.KEYDOWN and event.key == pygame.K_LEFT) or - (event.type == pygame.JOYHATMOTION and event.value[0] == -1) or - (event.type == pygame.JOYBUTTONDOWN and event.button == 2) - ): - if current_case and current_case.allow_snap and can_use_tilt and can_tip: - current_case.animate_tip() - can_use_tilt = False - elif ( - (event.type == pygame.KEYDOWN and event.key == pygame.K_UP) or - (event.type == pygame.JOYHATMOTION and event.value[1] == 1) or - (event.type == pygame.JOYBUTTONDOWN and event.button == 3) - ): - if current_case and current_case.allow_snap and can_use_on_top and current_case.can_place_on_top(): - if current_case.animate_place_on_top(): - transition_counter = transition_fps - sound_place.play() - stacked_cases.append(current_case) - current_case = spawn_case() - if current_case is None and not game_finished: - show_game_over(calculate_load_percent()) - game_finished = True - continue - can_use_tilt = True - can_use_on_top = True - else: - current_case.fail_snap() - sound_place.play() - stacked_cases.append(current_case) - current_case = spawn_case() - if current_case is None and not game_finished: - show_game_over(calculate_load_percent()) - game_finished = True - continue - shake_timer = 10 - elif ( - (event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE) or - (event.type == pygame.JOYBUTTONDOWN and event.button == 0) - ): - if current_case and current_case.allow_snap: - current_case.animate_snap() - can_use_tilt = True - can_use_on_top = True - sound_place.play() - transition_counter = transition_fps - stacked_cases.append(current_case) - current_case = spawn_case() - - if state == PLAYING: - if current_case is None: - continue - if current_case: - current_case.move() - if not game_finished: - can_tip = current_case.length_units * UNIT_HEIGHT <= TRAILER_HEIGHT - if collision_x + current_case.width > TRAILER_X + TRAILER_WIDTH and not current_case.can_place_on_top() and not current_case.allow_snap and not can_tip: - show_game_over(calculate_load_percent()) - game_finished = True - continue - if calculate_load_percent() >= 99.9 and case_index >= len(case_sequence): - show_game_over(100) - game_finished = True - continue - if not current_case.placed and current_case.collides_with_barrier(): - current_case.fail_snap() - if current_case.x + current_case.width > TRAILER_X + TRAILER_WIDTH: - if stacked_cases and stacked_cases[-1] == current_case: - stacked_cases.pop() - else: - stacked_cases.append(current_case) - can_use_tilt = True - can_use_on_top = True - sound_fail.play() - current_case = spawn_case() - shake_timer = 10 - draw_game() - - pygame.display.flip() - -pygame.quit() -sys.exit() diff --git a/loader_game/__init__.py b/loader_game/__init__.py new file mode 100644 index 0000000..1ef3ff5 --- /dev/null +++ b/loader_game/__init__.py @@ -0,0 +1,18 @@ +"""Habegger Loader Game – Spielpaket. + +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 +""" + +__version__ = "1.0.0" diff --git a/loader_game/assets.py b/loader_game/assets.py new file mode 100644 index 0000000..1755105 --- /dev/null +++ b/loader_game/assets.py @@ -0,0 +1,176 @@ +"""Laden und Zwischenspeichern aller Bilder, Sounds und Fonts.""" + +from __future__ import annotations + +import array +import math +import re + +import pygame + +from . import config + +_CASE_FILENAME = re.compile(r"^case_w(\d+)_h(\d+)\.png$") + + +class Assets: + """Sammelstelle für alles, was aus Dateien kommt. + + Muss **nach** ``pygame.init()`` und ``pygame.display.set_mode()`` + instanziiert werden, weil ``convert_alpha()`` ein Display voraussetzt. + Fehlende Dateien führen nicht zum Absturz, sondern zu ``None`` – das + Spiel zeichnet dann einen farbigen Platzhalter bzw. bleibt stumm. + """ + + def __init__(self) -> None: + self.font: pygame.font.Font | None = None + self.font_small: pygame.font.Font | None = None + self.background: pygame.Surface | None = None + self.instruction: pygame.Surface | None = None + self.sounds: dict[str, pygame.mixer.Sound] = {} + self._case_originals: dict[tuple[int, int], pygame.Surface] = {} + self._case_cache: dict[tuple[int, int], pygame.Surface] = {} + self._roll_channel: pygame.mixer.Channel | None = None + + # ------------------------------------------------------------------ + # Laden + # ------------------------------------------------------------------ + def load(self) -> None: + self.font = self._load_font(config.FONT_SIZE) + self.font_small = self._load_font(config.FONT_SIZE_SMALL) + self.background = self._load_image(config.BACKGROUND_IMAGE, (config.SCREEN_WIDTH, config.GAME_AREA_HEIGHT)) + self.instruction = self._load_image(config.TUTORIAL_IMAGE, (config.SCREEN_WIDTH, config.SCREEN_HEIGHT)) + self._load_case_images() + self._load_sounds() + self._load_beeps() + + def _load_font(self, size: int) -> pygame.font.Font: + try: + return pygame.font.Font(str(config.FONT_REGULAR), size) + except (OSError, pygame.error): + return pygame.font.SysFont(None, size) + + @staticmethod + def _load_image(path, size: tuple[int, int] | None = None) -> pygame.Surface | None: + if not path.exists(): + return None + try: + image = pygame.image.load(str(path)) + image = image.convert_alpha() if image.get_alpha() is not None else image.convert() + except pygame.error: + return None + if size is not None: + image = pygame.transform.smoothscale(image, size) + return image + + def _load_case_images(self) -> None: + if not config.IMAGES_DIR.is_dir(): + return + for path in sorted(config.IMAGES_DIR.glob("case_w*_h*.png")): + match = _CASE_FILENAME.match(path.name) + if not match: + continue + key = (int(match.group(1)), int(match.group(2))) + try: + self._case_originals[key] = pygame.image.load(str(path)).convert_alpha() + except pygame.error: + continue + + def _load_sounds(self) -> None: + if not pygame.mixer.get_init(): + return + for name, path in config.SOUND_FILES.items(): + if not path.exists(): + continue + try: + self.sounds[name] = pygame.mixer.Sound(str(path)) + except pygame.error: + continue + if "roll" in self.sounds: + self.sounds["roll"].set_volume(0.3) + for name in ("place", "fail"): + if name in self.sounds: + self.sounds[name].set_volume(0.6) + if "zip" in self.sounds: + self.sounds["zip"].set_volume(1.0) + pygame.mixer.set_num_channels(max(8, pygame.mixer.get_num_channels())) + self._roll_channel = pygame.mixer.Channel(1) + + def _load_beeps(self) -> None: + if not pygame.mixer.get_init(): + return + for index, spec in enumerate(config.INTRO_BEEPS, start=1): + self.sounds[f"beep{index}"] = self._make_beep(**spec) + + # ------------------------------------------------------------------ + # Koffer-Grafiken + # ------------------------------------------------------------------ + def case_surface(self, units_w: int, units_h: int) -> pygame.Surface | None: + """Skalierte Grafik für einen Koffer der Grösse ``units_w`` x ``units_h``. + + Fehlt die passende Datei, wird die gespiegelte Variante um 90° gedreht. + Gibt ``None`` zurück, wenn es gar keine Grafik gibt. + """ + key = (units_w, units_h) + cached = self._case_cache.get(key) + if cached is not None: + return cached + + size = (int(units_w * config.UNIT_LENGTH), int(units_h * config.UNIT_HEIGHT)) + natural = self._case_originals.get(key) + surface: pygame.Surface | None + if natural is not None: + surface = pygame.transform.smoothscale(natural, size) + else: + mirrored_key = (units_h, units_w) + mirrored = self._case_originals.get(mirrored_key) + if mirrored is None: + return None + mirrored_size = ( + int(units_h * config.UNIT_LENGTH), + int(units_w * config.UNIT_HEIGHT), + ) + surface = pygame.transform.rotate( + pygame.transform.smoothscale(mirrored, mirrored_size), 90 + ) + self._case_cache[key] = surface + return surface + + # ------------------------------------------------------------------ + # Sound-Steuerung + # ------------------------------------------------------------------ + def play(self, name: str, loops: int = 0) -> None: + sound = self.sounds.get(name) + if sound is None: + return + if name == "roll" and self._roll_channel is not None: + if not self._roll_channel.get_busy(): + self._roll_channel.play(sound, loops=loops) + return + sound.play(loops=loops) + + def stop_roll(self) -> None: + if self._roll_channel is not None: + self._roll_channel.stop() + + def stop_all(self) -> None: + if pygame.mixer.get_init(): + pygame.mixer.stop() + + # ------------------------------------------------------------------ + # Töne ohne Dateien (für das Intro) + # ------------------------------------------------------------------ + @staticmethod + def _make_beep(frequency: int = 440, duration_ms: int = 500, volume: float = 0.5): + """Erzeugt einen Sinuston als ``pygame.mixer.Sound`` (ohne numpy).""" + init = pygame.mixer.get_init() + if not init: + raise pygame.error("Mixer ist nicht initialisiert") + sample_rate, _, channels = init + sample_count = int(sample_rate * duration_ms / 1000.0) + buffer = array.array("h") + for index in range(sample_count): + value = int(math.sin(2 * math.pi * frequency * index / sample_rate) * volume * 32767) + for _ in range(channels): + buffer.append(value) + return pygame.mixer.Sound(buffer=buffer.tobytes()) diff --git a/loader_game/case.py b/loader_game/case.py new file mode 100644 index 0000000..7b4c519 --- /dev/null +++ b/loader_game/case.py @@ -0,0 +1,71 @@ +"""Der Koffer: Geometrie und Einlaufbewegung. + +Bewusst ohne ``pygame``-Abhängigkeit, damit die Regeln testbar bleiben. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import config + + +@dataclass +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). + """ + + units_w: int + units_h: int + x: float + y: float + color: tuple[int, int, int] = config.CASE_COLORS[0] + placed: bool = False + snapped: bool = False + allow_snap: bool = False + can_rotate: bool = True + + # -- Geometrie ------------------------------------------------------ + @property + def width(self) -> float: + return self.units_w * config.UNIT_LENGTH + + @property + def height(self) -> float: + return self.units_h * config.UNIT_HEIGHT + + @property + def right(self) -> float: + return self.x + self.width + + @property + def bottom(self) -> float: + return self.y + self.height + + # -- Bewegung ------------------------------------------------------- + def move(self, dt: float, wall_x: float) -> 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 + if distance > config.FAST_TO_SLOW_DISTANCE + else config.CASE_SPEED_SLOW + ) + self.x -= speed * dt + if self.x <= wall_x + config.SNAP_THRESHOLD: + self.allow_snap = True + + def rotate_footprint(self) -> None: + """Tauscht Breite und Höhe (nach einer 90°-Drehung).""" + self.units_w, self.units_h = self.units_h, self.units_w + + def fits_trailer_height(self) -> bool: + """Passt der Koffer nach einer Drehung noch in die Höhe des Aufliegers?""" + return self.width <= config.TRAILER_HEIGHT diff --git a/loader_game/config.py b/loader_game/config.py new file mode 100644 index 0000000..da4ce0e --- /dev/null +++ b/loader_game/config.py @@ -0,0 +1,151 @@ +"""Zentrale Konfiguration: alle Masse, Farben, Tempi und Pfade. + +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. +""" + +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" +FONT_REGULAR = FONTS_DIR / "Tisa Sans Pro Regular.ttf" + +BACKGROUND_IMAGE = IMAGES_DIR / "background.png" +TUTORIAL_IMAGE = IMAGES_DIR / "tutorial.png" +SEQUENCES_FILE = BASE_DIR / "case_sequences.txt" + +SOUND_FILES = { + "roll": BASE_DIR / "roll.ogg", + "place": BASE_DIR / "place.ogg", + "fail": BASE_DIR / "fail.ogg", + "zip": BASE_DIR / "zip.ogg", +} + +# -------------------------------------------------------------------------- +# Fenster / Bildrate +# -------------------------------------------------------------------------- +SCREEN_WIDTH = 1920 +SCREEN_HEIGHT = 1080 +WINDOW_TITLE = "Habegger Trailer Load" +FPS = 60 +AUDIO_SAMPLE_RATE = 44100 +DEFAULT_FULLSCREEN = False # per --fullscreen oder F11 umschaltbar + +# -------------------------------------------------------------------------- +# Layout (horizontale Bänder) +# -------------------------------------------------------------------------- +TOP_BAR_HEIGHT = SCREEN_HEIGHT // 8 # 135 +GAME_AREA_HEIGHT = int(SCREEN_HEIGHT / 1.6) # 675 +BOTTOM_BAR_HEIGHT = SCREEN_HEIGHT // 4 # 270 + +# -------------------------------------------------------------------------- +# Auflieger (grauer Kasten, in dem die Koffer gestapelt werden) +# -------------------------------------------------------------------------- +TRAILER_WIDTH = int(SCREEN_WIDTH * 0.885416666667) # 1700 +TRAILER_HEIGHT = int(SCREEN_HEIGHT * 0.2778) # 300 +TRAILER_X = (SCREEN_WIDTH - TRAILER_WIDTH) // 4 # 55 +TRAILER_Y = TOP_BAR_HEIGHT + 200 # 335 +TRAILER_RIGHT = TRAILER_X + TRAILER_WIDTH +TRAILER_BOTTOM = TRAILER_Y + TRAILER_HEIGHT + +# Eine Kasten-Einheit entspricht 100x100 Pixeln. +UNIT_LENGTH = TRAILER_HEIGHT // 3 # 100 +UNIT_HEIGHT = TRAILER_HEIGHT // 3 # 100 +MAX_UNITS_W = 4 +MAX_UNITS_H = 3 + +# -------------------------------------------------------------------------- +# Spieltempo (jetzt in Pixel pro Sekunde statt pro Frame) +# -------------------------------------------------------------------------- +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" + +# Animationsdauern in Sekunden (vorher: 10 Frames à 16,6 ms) +ANIM_SNAP = 0.17 +ANIM_ROTATE = 0.18 +ANIM_STACK_STEP = 0.17 +SHAKE_TIME = 0.12 +SHAKE_AMPLITUDE = 5 + +# -------------------------------------------------------------------------- +# Intro +# -------------------------------------------------------------------------- +INTRO_DOT_COLORS = ((255, 0, 0), (255, 200, 0), (0, 255, 0)) +INTRO_DOT_DELAY = 1.0 +INTRO_DOT_RADIUS = 90 +INTRO_ZIP_DURATION = 0.2 +INTRO_BEEPS = ( + {"frequency": 440, "duration_ms": 600, "volume": 0.5}, + {"frequency": 440, "duration_ms": 400, "volume": 0.5}, + {"frequency": 600, "duration_ms": 1000, "volume": 0.5}, +) + +# -------------------------------------------------------------------------- +# Farben +# -------------------------------------------------------------------------- +COLOR_TOP_BAR = (0, 0, 0) +COLOR_BOTTOM_BAR = (0, 0, 0) +COLOR_BACKGROUND = (0, 0, 0) +COLOR_TRAILER = (100, 100, 100) +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_PROGRESS_TRACK = (50, 50, 50) +CASE_COLORS = ((200, 0, 0), (0, 200, 0), (0, 0, 200)) + +# -------------------------------------------------------------------------- +# Fortschrittsbalken +# -------------------------------------------------------------------------- +PROGRESS_BAR_SEGMENTS = 5 +PROGRESS_BAR_WIDTH = int(SCREEN_WIDTH * 0.25) +PROGRESS_BAR_HEIGHT = 30 +PROGRESS_BAR_RIGHT_MARGIN = 140 +PROGRESS_BAR_GAP = 8 +PROGRESS_BAR_RADIUS = 10 + +# -------------------------------------------------------------------------- +# Texte +# -------------------------------------------------------------------------- +FONT_SIZE = 48 +FONT_SIZE_SMALL = 34 +TITLE_TEXT = WINDOW_TITLE +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 +# -------------------------------------------------------------------------- +DEFAULT_PLAYER_NAME = "Spieler" +MAX_PLAYER_NAME_LENGTH = 14 + +# 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 diff --git a/loader_game/game.py b/loader_game/game.py new file mode 100644 index 0000000..5de59be --- /dev/null +++ b/loader_game/game.py @@ -0,0 +1,333 @@ +"""Zustandsmaschine des Spiels: Koffer einlaufen lassen, stapeln, auswerten.""" + +from __future__ import annotations + +import enum +import random +from dataclasses import dataclass + +from . import config, scene +from .case import Case +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" + + +@dataclass +class Animation: + """Kurze Zwischenanimation für den aktuellen Koffer.""" + + kind: str # "snap" | "rotate" | "stack" + duration: float + start_x: float + start_y: float + target_x: float = 0.0 + target_y: float = 0.0 + base: Case | None = None + elapsed: float = 0.0 + + @property + def progress(self) -> float: + if self.duration <= 0: + return 1.0 + return min(1.0, self.elapsed / self.duration) + + +class Game: + def __init__( + self, + assets, + sequences, + *, + name: str = config.DEFAULT_PLAYER_NAME, + 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.state = State.PLAYING + self.trailer = Trailer() + source = sequences if sequences else [list(FALLBACK_SEQUENCE)] + self.sequence: list[CaseSpec] = list(self.rng.choice(source)) + + self.index = 0 + self.current: Case | None = None + self.animation: Animation | None = None + self.shake = 0.0 + self.percent = 0.0 + self.status_line = "" + self.status_is_error = False + + self._spawn() + + # ------------------------------------------------------------------ + # Sequenz / Nachschub + # ------------------------------------------------------------------ + def has_next(self) -> bool: + return self.index < len(self.sequence) + + def upcoming(self, count: int = config.NEXT_PREVIEW_COUNT) -> list[CaseSpec]: + """Die nächsten Koffer für die "Next"-Vorschau.""" + return self.sequence[self.index : self.index + count] + + def _spawn(self) -> None: + if not self.has_next(): + self.current = None + self._finish() + return + spec = self.sequence[self.index] + self.index += 1 + self.current = Case( + units_w=spec.units_w, + units_h=spec.units_h, + x=float(config.SCREEN_WIDTH), + y=float(config.TRAILER_BOTTOM - spec.units_h * config.UNIT_HEIGHT), + color=self.rng.choice(config.CASE_COLORS), + ) + self._play("roll", loops=-1) + + # ------------------------------------------------------------------ + # Eingabe + # ------------------------------------------------------------------ + def handle(self, action: Action) -> bool: + """Verarbeitet eine Aktion. Rückgabe ``False`` = Spiel beenden.""" + if action is Action.BACK: + return False + + if self.state is State.GAME_OVER: + return action is not Action.PRIMARY + + if self.animation is not None or self.current is None: + return True + + case = self.current + if case.placed or not case.allow_snap: + return True + + if action is Action.PRIMARY: + self._start_snap(case) + elif action is Action.LEFT: + self._start_rotate(case) + elif action is Action.UP: + self._start_stack(case) + return True + + def _start_snap(self, case: Case) -> None: + self._stop_roll() + self.animation = Animation( + kind="snap", + duration=config.ANIM_SNAP, + start_x=case.x, + start_y=case.y, + target_x=self.trailer.wall_x, + target_y=case.y, + ) + + def _start_rotate(self, case: Case) -> None: + if not case.can_rotate or not case.fits_trailer_height(): + return + self._stop_roll() + self.animation = Animation( + kind="rotate", + duration=config.ANIM_ROTATE, + start_x=case.x, + start_y=case.y, + ) + + def _start_stack(self, case: Case) -> None: + base = self.trailer.find_platform(case) + if base is not None: + self._stop_roll() + self.animation = Animation( + kind="stack", + duration=config.ANIM_STACK_STEP * 2, + start_x=case.x, + start_y=case.y, + target_x=base.right - case.width, + target_y=base.y - case.height, + base=base, + ) + return + # Kein Platz obendrauf: wie im Altstand wird der Koffer mit Lücke + # eingereiht und das Spiel schüttelt sich. + self._stack_with_gap(case, sound="place") + + # ------------------------------------------------------------------ + # Update + # ------------------------------------------------------------------ + def update(self, dt: float) -> None: + if self.shake > 0: + self.shake = max(0.0, self.shake - dt) + if self.state is not State.PLAYING: + return + + if self.animation is not None: + self._advance_animation(dt) + return + + case = self.current + if case is None: + self._finish() + return + + case.move(dt, self.trailer.wall_x) + if not case.placed and case.x <= self.trailer.wall_x: + self._stack_with_gap(case, sound="fail") + return + self._check_dead_end(case) + + def _advance_animation(self, dt: float) -> None: + animation = self.animation + case = self.current + if animation is None or case is None: + self.animation = None + return + + animation.elapsed += dt + progress = animation.progress + + if animation.kind == "snap": + case.x = animation.start_x + (animation.target_x - animation.start_x) * progress + elif animation.kind == "stack": + if progress <= 0.5: + factor = progress / 0.5 + case.y = animation.start_y + (animation.target_y - animation.start_y) * factor + else: + factor = (progress - 0.5) / 0.5 + case.y = animation.target_y + case.x = animation.start_x + (animation.target_x - animation.start_x) * factor + + if progress >= 1.0: + self._finish_animation(animation) + + def _finish_animation(self, animation: Animation) -> None: + self.animation = None + case = self.current + if case is None: + return + + if animation.kind == "rotate": + case.rotate_footprint() + case.y = float(config.TRAILER_BOTTOM - case.height) + case.x = max(self.trailer.wall_x + config.SNAP_THRESHOLD // 2, case.x) + case.can_rotate = False + return + + case.x, case.y = animation.target_x, animation.target_y + if animation.kind == "snap": + self.trailer.stack_at_wall(case) + else: + self.trailer.place_on_top(case, animation.base) + self._play("place") + self._next_round() + + def _stack_with_gap(self, case: Case, *, sound: str) -> None: + self._stop_roll() + self.trailer.stack_with_gap(case) + if case.right > self.trailer.right: + self.trailer.remove(case) + self._play(sound) + self.shake = config.SHAKE_TIME + self._next_round() + + def _next_round(self) -> None: + if self.trailer.overflow(): + self._finish() + return + self._spawn() + + def _check_dead_end(self, case: Case) -> None: + """Ist der Koffer weder einrastbar, noch stapelbar, noch drehbar?""" + if case.allow_snap: + return + if self.trailer.wall_x + case.width <= self.trailer.right: + return + if self.trailer.find_platform(case) is not None: + return + if case.can_rotate and case.fits_trailer_height(): + return + self._finish() + + def _finish(self) -> None: + if self.state is State.GAME_OVER: + return + self.state = State.GAME_OVER + 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_background(surface, assets) + + offset = (0, 0) + if self.shake > 0: + offset = ( + self.rng.randint(-config.SHAKE_AMPLITUDE, config.SHAKE_AMPLITUDE), + self.rng.randint(-config.SHAKE_AMPLITUDE, config.SHAKE_AMPLITUDE), + ) + scene.draw_trailer(surface, offset) + for stacked in self.trailer.stack: + scene.draw_case(surface, assets, stacked) + + case = self.current + if case is not None: + angle = 0.0 + if self.animation is not None and self.animation.kind == "rotate": + angle = -90.0 * self.animation.progress + scene.draw_case(surface, assets, case, angle=angle) + + scene.draw_wall(surface, self.trailer.wall_x, offset) + scene.draw_bottom_bar(surface, assets, self.upcoming()) + + if case is not None and case.allow_snap and self.state is State.PLAYING: + scene.draw_hint( + 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, + ) + + 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] + + # ------------------------------------------------------------------ + # Hilfen + # ------------------------------------------------------------------ + def _play(self, name: str, loops: int = 0) -> None: + if self.assets is not None: + self.assets.play(name, loops=loops) + + def _stop_roll(self) -> None: + if self.assets is not None: + self.assets.stop_roll() diff --git a/loader_game/input_map.py b/loader_game/input_map.py new file mode 100644 index 0000000..3da2a35 --- /dev/null +++ b/loader_game/input_map.py @@ -0,0 +1,108 @@ +"""Tastatur und Controller → abstrakte Aktionen. + +Die Aktionen sind absichtlich generisch gehalten. Die spätere Namenseingabe +per Controller benutzt dieselben Richtungen (Buchstabe weiter/zurück, +Position wechseln), deshalb gibt es hier keine spielspezifischen Namen. +""" + +from __future__ import annotations + +import enum + +import pygame + +from . import config + + +class Action(enum.Enum): + """Abstrakte Eingabe – unabhängig davon, ob Tastatur oder Controller.""" + + PRIMARY = "primary" # Space / Enter / Controller A + LEFT = "left" # Pfeil links / D-Pad links + RIGHT = "right" # Pfeil rechts / D-Pad rechts + UP = "up" # Pfeil hoch / D-Pad hoch + DOWN = "down" # Pfeil runter / D-Pad runter + BACK = "back" # Esc / Controller B + + +#: Tastaturbelegung +KEY_ACTIONS: dict[int, Action] = { + pygame.K_SPACE: Action.PRIMARY, + pygame.K_RETURN: Action.PRIMARY, + pygame.K_KP_ENTER: Action.PRIMARY, + pygame.K_LEFT: Action.LEFT, + pygame.K_RIGHT: Action.RIGHT, + pygame.K_UP: Action.UP, + pygame.K_DOWN: Action.DOWN, + pygame.K_ESCAPE: Action.BACK, +} + +#: Controller-Tasten (Xbox-Layout: 0=A, 1=B, 2=X, 3=Y) +BUTTON_ACTIONS: dict[int, Action] = { + 0: Action.PRIMARY, + 1: Action.BACK, + 2: Action.LEFT, + 3: Action.UP, +} + +#: D-Pad, das als Hat gemeldet wird +HAT_ACTIONS: dict[tuple[int, int], Action] = { + (-1, 0): Action.LEFT, + (1, 0): Action.RIGHT, + (0, -1): Action.UP, + (0, 1): Action.DOWN, +} + +#: Analogstick: ab diesem Ausschlag gilt eine Richtung als gewollt +AXIS_DEADZONE = 0.6 + + +def actions_from_event(event: pygame.event.Event) -> list[Action]: + """Übersetzt ein pygame-Event in null bis eine Aktion.""" + if event.type == pygame.KEYDOWN: + action = KEY_ACTIONS.get(event.key) + return [action] if action else [] + + if event.type == pygame.JOYBUTTONDOWN: + action = BUTTON_ACTIONS.get(event.button) + return [action] if action else [] + + if event.type == pygame.JOYHATMOTION: + action = HAT_ACTIONS.get(tuple(event.value)) + return [action] if action else [] + + if event.type == pygame.JOYAXISMOTION: + if event.axis == 0 and abs(event.value) >= AXIS_DEADZONE: + return [Action.RIGHT if event.value > 0 else Action.LEFT] + if event.axis == 1 and abs(event.value) >= AXIS_DEADZONE: + return [Action.DOWN if event.value > 0 else Action.UP] + return [] + + +def connected_controllers() -> list[str]: + """Namen aller angeschlossenen Controller (leer, wenn keiner da ist).""" + names = [] + for index in range(pygame.joystick.get_count()): + try: + stick = pygame.joystick.Joystick(index) + stick.init() + names.append(stick.get_name()) + except pygame.error: + continue + return names + + +def describe_controller_setup() -> str: + names = connected_controllers() + if not names: + return "Controller: keiner erkannt (Tastatur: Space=Snap, ←=Drehen, ↑=OnTop, Esc=Ende)" + return "Controller: " + ", ".join(names) + + +def controller_hint_lines() -> list[str]: + """Kurzhilfe fürs Log beim Start.""" + return [ + describe_controller_setup(), + f"Zielauflösung {config.SCREEN_WIDTH}x{config.SCREEN_HEIGHT}, " + f"{config.FPS} fps", + ] diff --git a/loader_game/intro.py b/loader_game/intro.py new file mode 100644 index 0000000..6a62e35 --- /dev/null +++ b/loader_game/intro.py @@ -0,0 +1,82 @@ +"""Tutorialbild, Ampel-Startsequenz und Reissverschluss-Effekt.""" + +from __future__ import annotations + +import pygame + +from . import config, scene +from .input_map import Action + + +class Intro: + """Vorspann vor dem ersten Koffer. + + Ablauf: Tutorialbild (wartet auf Bestätigung) → drei Punkte mit Ton → + Reissverschluss, der den Auflieger "öffnet". + """ + + def __init__(self, assets) -> None: + self.assets = assets + self.phase = "instruction" if assets.instruction is not None else "countdown" + self.timer = 0.0 + self.dot_index = 0 + if self.phase == "countdown": + self._start_countdown() + + # ------------------------------------------------------------------ + def _start_countdown(self) -> None: + self.phase = "countdown" + self.timer = 0.0 + self.dot_index = 0 + self.assets.play("beep1") + + def handle(self, action: Action) -> None: + if self.phase == "instruction" and action is Action.PRIMARY: + self._start_countdown() + + def update(self, dt: float) -> bool: + """Weiterrechnen; Rückgabe ``True``, wenn der Vorspann fertig ist.""" + self.timer += dt + if self.phase == "countdown": + index = int(self.timer // config.INTRO_DOT_DELAY) + if index >= len(config.INTRO_DOT_COLORS): + self.phase = "zip" + self.timer = 0.0 + self.assets.play("zip") + elif index != self.dot_index: + self.dot_index = index + self.assets.play(f"beep{index + 1}") + return False + if self.phase == "zip": + return self.timer >= config.INTRO_ZIP_DURATION + return False + + # ------------------------------------------------------------------ + def draw(self, surface: pygame.Surface) -> None: + if self.phase == "instruction": + surface.blit(self.assets.instruction, (0, 0)) + return + + scene.draw_top_bar(surface, self.assets, 0.0) + scene.draw_background(surface, self.assets) + scene.draw_bottom_bar(surface, self.assets, []) + + if self.phase == "countdown": + if 0 <= self.dot_index < len(config.INTRO_DOT_COLORS): + pygame.draw.circle( + surface, + config.INTRO_DOT_COLORS[self.dot_index], + ( + config.TRAILER_X + config.TRAILER_WIDTH // 2, + config.TRAILER_Y + config.TRAILER_HEIGHT // 2, + ), + config.INTRO_DOT_RADIUS, + ) + elif self.phase == "zip": + progress = min(1.0, self.timer / config.INTRO_ZIP_DURATION) if config.INTRO_ZIP_DURATION else 1.0 + width = int(config.TRAILER_WIDTH * progress) + pygame.draw.rect( + surface, + config.COLOR_TRAILER, + (config.TRAILER_X, config.TRAILER_Y, width, config.TRAILER_HEIGHT), + ) diff --git a/loader_game/net.py b/loader_game/net.py new file mode 100644 index 0000000..cd3f692 --- /dev/null +++ b/loader_game/net.py @@ -0,0 +1,47 @@ +"""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 new file mode 100644 index 0000000..86d23fd --- /dev/null +++ b/loader_game/scene.py @@ -0,0 +1,205 @@ +"""Zeichenroutinen – alles, was Pixel auf den Bildschirm bringt.""" + +from __future__ import annotations + +import pygame + +from . import config +from .case import Case +from .sequences import CaseSpec + + +# -------------------------------------------------------------------------- +# Hintergrund-Bänder +# -------------------------------------------------------------------------- +def draw_top_bar(surface: pygame.Surface, assets, percent: float) -> 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)) + _draw_progress_bar(surface, assets, percent) + + +def _draw_progress_bar(surface: pygame.Surface, assets, percent: float) -> None: + bar_width = config.PROGRESS_BAR_WIDTH + bar_height = config.PROGRESS_BAR_HEIGHT + x = config.SCREEN_WIDTH - bar_width - config.PROGRESS_BAR_RIGHT_MARGIN + y = config.TOP_BAR_HEIGHT // 2 - bar_height // 2 + gap = config.PROGRESS_BAR_GAP + segment_width = (bar_width - (config.PROGRESS_BAR_SEGMENTS - 1) * gap) / config.PROGRESS_BAR_SEGMENTS + + red = min(255, int(percent * 2.55)) + green = max(0, 255 - int(percent * 2.55)) + color = (red, green, 0) + + for index in range(config.PROGRESS_BAR_SEGMENTS): + segment_x = x + index * (segment_width + gap) + rect = (segment_x, y, segment_width, bar_height) + pygame.draw.rect(surface, config.COLOR_PROGRESS_TRACK, rect, border_radius=config.PROGRESS_BAR_RADIUS) + if percent >= (index + 1) * 20: + pygame.draw.rect(surface, color, rect, border_radius=config.PROGRESS_BAR_RADIUS) + + text = assets.font.render(f"{int(percent)}%", True, config.COLOR_WHITE) + surface.blit(text, (x + bar_width + 10, y)) + + +def draw_background(surface: pygame.Surface, assets) -> None: + if assets.background is not None: + surface.blit(assets.background, (0, config.TOP_BAR_HEIGHT)) + else: + pygame.draw.rect( + surface, + config.COLOR_BACKGROUND, + (0, config.TOP_BAR_HEIGHT, config.SCREEN_WIDTH, config.GAME_AREA_HEIGHT), + ) + + +def draw_bottom_bar(surface: pygame.Surface, assets, upcoming: list[CaseSpec]) -> None: + surface.fill( + config.COLOR_BOTTOM_BAR, + (0, config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT, config.SCREEN_WIDTH, 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) + surface.blit(label, (40, top + 12)) + + preview_x = 40 + for index, spec in enumerate(upcoming[: config.NEXT_PREVIEW_COUNT]): + image = _preview_surface(assets, spec) + height = image.get_height() if image is not None else 0 + y = config.SCREEN_HEIGHT - config.NEXT_PREVIEW_MARGIN - height + if image is None: + width, height = _spec_size(spec) + y = config.SCREEN_HEIGHT - config.NEXT_PREVIEW_MARGIN - height + pygame.draw.rect( + surface, + config.CASE_COLORS[index % len(config.CASE_COLORS)], + (preview_x, y, width, height), + ) + else: + width = image.get_width() + surface.blit(image, (preview_x, y)) + preview_x += max(width, 60) + 20 + + +_PREVIEW_CACHE: dict[tuple[int, int], pygame.Surface | None] = {} + + +def _preview_surface(assets, spec: CaseSpec) -> pygame.Surface | None: + """Vorschau-Grafik in verkleinertem Massstab (gecacht).""" + key = (spec.units_w, spec.units_h) + if key in _PREVIEW_CACHE: + return _PREVIEW_CACHE[key] + image = assets.case_surface(spec.units_w, spec.units_h) if assets is not None else None + if image is not None: + size = ( + max(1, int(image.get_width() * config.NEXT_PREVIEW_SCALE)), + max(1, int(image.get_height() * config.NEXT_PREVIEW_SCALE)), + ) + image = pygame.transform.smoothscale(image, size) + _PREVIEW_CACHE[key] = image + return image + + +# -------------------------------------------------------------------------- +# Auflieger +# -------------------------------------------------------------------------- +def draw_trailer(surface: pygame.Surface, offset: tuple[int, int] = (0, 0)) -> None: + pygame.draw.rect( + surface, + config.COLOR_TRAILER, + ( + config.TRAILER_X + offset[0], + config.TRAILER_Y + offset[1], + config.TRAILER_WIDTH, + config.TRAILER_HEIGHT, + ), + ) + + +def draw_wall(surface: pygame.Surface, wall_x: float, offset: tuple[int, int] = (0, 0)) -> None: + pygame.draw.line( + surface, + config.COLOR_WALL, + (wall_x + offset[0], config.TRAILER_Y + offset[1]), + (wall_x + offset[0], config.TRAILER_BOTTOM + offset[1]), + 3, + ) + + +# -------------------------------------------------------------------------- +# Koffer +# -------------------------------------------------------------------------- +def _spec_size(spec: CaseSpec) -> tuple[int, int]: + return ( + int(spec.units_w * config.UNIT_LENGTH), + int(spec.units_h * config.UNIT_HEIGHT), + ) + + +def draw_case(surface: pygame.Surface, assets, case: Case, angle: float = 0.0) -> None: + 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 + if image is None: + pygame.draw.rect(surface, case.color, (x, y, width, height)) + return + if angle: + rotated = pygame.transform.rotate(image, angle) + surface.blit(rotated, rotated.get_rect(bottomleft=(x, y + height))) + else: + surface.blit(image, (x, y)) + + +# -------------------------------------------------------------------------- +# Hinweise und Overlays +# -------------------------------------------------------------------------- +def draw_hint(surface: pygame.Surface, assets, stack_possible: bool) -> None: + if stack_possible: + text, color = config.HINT_STACK, config.COLOR_HINT_STACK + else: + text, color = config.HINT_SNAP, config.COLOR_HINT_SNAP + rendered = assets.font.render(text, True, color) + surface.blit( + rendered, + ( + config.SCREEN_WIDTH - config.SCREEN_WIDTH // 3 - rendered.get_width() // 2, + config.SCREEN_HEIGHT - config.BOTTOM_BAR_HEIGHT // 2 - rendered.get_height() // 2, + ), + ) + + +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)) + + center_y = config.SCREEN_HEIGHT // 2 + line_gap = 16 + total_height = 0 + rendered: list[pygame.Surface] = [] + for line in lines: + image = assets.font.render(line, True, config.COLOR_WHITE) + rendered.append(image) + total_height += image.get_height() + line_gap + y = center_y - total_height // 2 + 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/sequences.py b/loader_game/sequences.py new file mode 100644 index 0000000..cfb2026 --- /dev/null +++ b/loader_game/sequences.py @@ -0,0 +1,107 @@ +"""Einlesen der Koffersequenzen aus ``case_sequences.txt``. + +Dateiformat +----------- +Sequenzen sind durch Leerzeilen getrennt. Jede Zeile beschreibt einen Koffer:: + + breite,höhe,rotiert + +* ``breite`` / ``höhe`` sind Kasten-Einheiten (1 Einheit = 100x100 Pixel). +* ``rotiert=True`` heisst: der Koffer wird beim Einlaufen um 90° gedreht, + Breite und Höhe werden also getauscht. + +Beispiel:: + + 3,1,False + 1,2,True -> Koffer ist effektiv 2 Einheiten breit und 1 hoch +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path + +from . import config + +_TRUE_VALUES = {"true", "1", "ja", "yes", "x"} + + +@dataclass(frozen=True, slots=True) +class CaseSpec: + """Ein Koffer aus der Sequenzdatei – bereits in Einlauf-Orientierung.""" + + units_w: int + units_h: int + + def __post_init__(self) -> None: + if self.units_w < 1 or self.units_h < 1: + raise ValueError(f"Koffer muss mindestens 1x1 Einheiten gross sein: {self!r}") + + @property + def label(self) -> str: + return f"{self.units_w}x{self.units_h}" + + +#: Notfall-Sequenz, falls die Datei fehlt oder unlesbar ist. +FALLBACK_SEQUENCE: tuple[CaseSpec, ...] = (CaseSpec(2, 1), CaseSpec(1, 2)) + + +def parse_sequences(raw: str) -> tuple[list[list[CaseSpec]], list[str]]: + """Zerlegt den Dateiinhalt in Sequenzen. + + Rückgabe: ``(sequenzen, warnungen)``. Ungültige Zeilen werden gemeldet und + übersprungen – ein Tippfehler in der Datei soll das Spiel nicht verhindern. + """ + sequences: list[list[CaseSpec]] = [] + warnings: list[str] = [] + + for block_number, block in enumerate(re.split(r"\n\s*\n", raw.strip()), start=1): + specs: list[CaseSpec] = [] + for line_number, line in enumerate(block.splitlines(), start=1): + text = line.strip() + if not text: + continue + parts = [part.strip() for part in text.split(",")] + if len(parts) != 3: + warnings.append( + f"Block {block_number}, Zeile {line_number}: " + f"'breite,höhe,rotiert' erwartet, gefunden {text!r}" + ) + continue + try: + units_w, units_h = int(parts[0]), int(parts[1]) + except ValueError: + warnings.append( + f"Block {block_number}, Zeile {line_number}: keine ganzen Zahlen in {text!r}" + ) + continue + rotated = parts[2].lower() in _TRUE_VALUES + if rotated: + units_w, units_h = units_h, units_w + if units_w < 1 or units_h < 1: + warnings.append( + f"Block {block_number}, Zeile {line_number}: ungültige Masse {text!r}" + ) + continue + specs.append(CaseSpec(units_w, units_h)) + if specs: + sequences.append(specs) + + if not sequences: + warnings.append("Keine gültige Sequenz gefunden – es wird die Notfall-Sequenz benutzt.") + return sequences, warnings + + +def load_sequences(path: Path | None = None) -> tuple[list[list[CaseSpec]], list[str]]: + """Liest die Sequenzdatei und liefert immer mindestens eine Sequenz.""" + path = path or config.SEQUENCES_FILE + try: + raw = path.read_text(encoding="utf-8") + except OSError as exc: + return [list(FALLBACK_SEQUENCE)], [f"Sequenzdatei {path} nicht lesbar: {exc}"] + + sequences, warnings = parse_sequences(raw) + if not sequences: + sequences = [list(FALLBACK_SEQUENCE)] + return sequences, warnings diff --git a/loader_game/storage.py b/loader_game/storage.py new file mode 100644 index 0000000..bc1a2c8 --- /dev/null +++ b/loader_game/storage.py @@ -0,0 +1,50 @@ +"""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/loader_game/trailer.py b/loader_game/trailer.py new file mode 100644 index 0000000..1f106d9 --- /dev/null +++ b/loader_game/trailer.py @@ -0,0 +1,124 @@ +"""Stapelregeln im Auflieger – reine Logik, ohne pygame. + +Begriffe +-------- +Barriere (``wall_x``) + Rechte Kante des bereits gestapelten Ladeguts. Neue Koffer werden dort + eingerastet. Reicht die Barriere über das Heck des Aufliegers hinaus, ist + das Spiel verloren. + +Plattform + Ein bereits platzierter Koffer, auf den der aktuelle Koffer obendrauf + gesetzt werden darf (nur bündig an der Barriere, nicht zu schmal, und die + Fläche obendrauf muss frei sein). +""" + +from __future__ import annotations + +from . import config +from .case import Case + + +class Trailer: + """Der Auflieger samt Stapel und Barrieren-Logik.""" + + def __init__( + self, + x: int = config.TRAILER_X, + y: int = config.TRAILER_Y, + width: int = config.TRAILER_WIDTH, + height: int = config.TRAILER_HEIGHT, + ) -> None: + self.x = x + self.y = y + self.width = width + self.height = height + self.stack: list[Case] = [] + self.wall_x: float = float(x) + + # ------------------------------------------------------------------ + # Masse + # ------------------------------------------------------------------ + @property + def right(self) -> float: + return self.x + self.width + + @property + def bottom(self) -> float: + return self.y + self.height + + def reset(self) -> None: + self.stack.clear() + self.wall_x = float(self.x) + + # ------------------------------------------------------------------ + # Auswertung + # ------------------------------------------------------------------ + def overflow(self) -> bool: + """Ragt die Barriere über das Heck hinaus?""" + return self.wall_x > self.right + + def load_percent(self) -> float: + """Beladung in Prozent (Fläche der Koffer / Fläche des Aufliegers).""" + total = self.width * self.height + if total <= 0: + return 0.0 + used = sum(case.width * case.height for case in self.stack) + return min(100.0, used / total * 100.0) + + def find_platform(self, case: Case) -> Case | None: + """Koffer, auf den ``case`` obendrauf gesetzt werden darf (oder ``None``).""" + for base in reversed(self.stack): + if base.y <= self.y: + continue # Oberkante = Auflieger-Oberkante + if base.y - case.height < self.y: + continue # oben kein Platz mehr + if case.width > base.width: + continue # zu breit für die Plattform + if abs(base.right - self.wall_x) >= config.PLATFORM_TOLERANCE: + continue # nicht bündig an der Barriere + if self._top_is_busy(base): + continue + return base + return None + + def _top_is_busy(self, base: Case) -> bool: + """Liegt schon ein Koffer auf ``base``?""" + for other in self.stack: + if other is base: + continue + if abs(other.bottom - base.y) <= 0.5 and other.x < base.right and other.right > base.x: + return True + return False + + # ------------------------------------------------------------------ + # Platzieren + # ------------------------------------------------------------------ + def stack_at_wall(self, case: Case) -> None: + """Koffer bündig an die Barriere schieben.""" + case.x = self.wall_x + case.placed = True + case.snapped = True + self.stack.append(case) + self.wall_x = case.right + + def stack_with_gap(self, case: Case) -> None: + """Koffer mit einer Lücke einreihen (nach einer Kollision).""" + case.x = self.wall_x + config.SNAP_THRESHOLD + case.placed = True + self.stack.append(case) + self.wall_x = case.right + + def place_on_top(self, case: Case, base: Case) -> None: + """Koffer rechtsbündig auf ``base`` ablegen.""" + case.y = base.y - case.height + case.x = base.right - case.width + case.placed = True + case.snapped = True + self.stack.append(case) + self.wall_x = case.right + + def remove(self, case: Case) -> None: + """Koffer wieder entfernen (er ragt über das Heck hinaus).""" + if case in self.stack: + self.stack.remove(case) diff --git a/main.py b/main.py new file mode 100755 index 0000000..99e4d79 --- /dev/null +++ b/main.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Einstiegspunkt des Habegger Loader Games. + +Beispiele:: + + ./run.sh # Fenster, letzter Spielername + ./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 random +import sys + +import pygame + +from loader_game import config, storage +from loader_game.assets import Assets +from loader_game.game import Game, State +from loader_game.input_map import Action, actions_from_event, controller_hint_lines +from loader_game.intro import Intro +from loader_game.sequences import load_sequences + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Habegger Loader Game") + parser.add_argument( + "--fullscreen", + 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, + default=None, + help="Zufalls-Seed für reproduzierbare Koffer-Sequenzen", + ) + parser.add_argument( + "--smoke", + type=float, + default=None, + metavar="SEKUNDEN", + help="Selbsttest: bestätigt das Intro automatisch, spielt ohne Eingaben " + "und beendet sich nach N Sekunden", + ) + return parser.parse_args(argv) + + +def create_screen(fullscreen: bool) -> pygame.Surface: + """Fenster mit virtueller 1920x1080-Auflösung anlegen. + + ``pygame.SCALED`` sorgt dafür, dass alles automatisch auf die tatsächliche + Fenster-/Bildschirmgrösse skaliert wird (Retina, 1440x900, 4K …). + """ + flags = pygame.SCALED | (pygame.FULLSCREEN if fullscreen else pygame.RESIZABLE) + size = (config.SCREEN_WIDTH, config.SCREEN_HEIGHT) + try: + return pygame.display.set_mode(size, flags, vsync=1) + except pygame.error: + return pygame.display.set_mode(size, flags) + + +def _demo_action(game: Game, rng: random.Random) -> Action | None: + """Einfache Auto-Strategie für den Selbsttest.""" + case = game.current + if case is None or game.animation is not None or not case.allow_snap: + return None + return rng.choice([Action.PRIMARY, Action.PRIMARY, Action.PRIMARY, Action.UP, Action.LEFT]) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + pygame.mixer.pre_init(config.AUDIO_SAMPLE_RATE, -16, 2, 512) + pygame.init() + if not pygame.display.get_init(): + print("Kein Display verfügbar (ohne Bildschirm gestartet?).", file=sys.stderr) + return 1 + + pygame.display.set_caption(config.WINDOW_TITLE) + screen = create_screen(fullscreen=args.fullscreen) + + assets = Assets() + assets.load() + + for line in controller_hint_lines(): + print(line) + + sequences, warnings = load_sequences() + 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() + + running = True + smoke_remaining = args.smoke + smoke_timer = 0.0 + + while running: + dt = min(clock.tick(config.FPS) / 1000.0, 0.1) + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + running = False + break + if event.type == pygame.KEYDOWN and event.key == pygame.K_F11: + 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 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) + + if smoke_remaining is not None: + smoke_remaining -= dt + if smoke_remaining <= 0: + running = False + elif game is not None and intro is None: + 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) + + if intro is not None: + intro.draw(screen) + elif game is not None: + game.draw(screen) + pygame.display.flip() + + if game is not None: + percent = game.percent if game.state is State.GAME_OVER else game.trailer.load_percent() + print( + f"Spiel beendet: Zustand={game.state.value}, " + f"Beladung={percent:.0f}%, Koffer im Auflieger={len(game.trailer.stack)}" + ) + + pygame.quit() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/qr_scan.py b/qr_scan.py deleted file mode 100644 index c099da1..0000000 --- a/qr_scan.py +++ /dev/null @@ -1,188 +0,0 @@ -import cv2 -from pyzbar.pyzbar import decode -import numpy as np -from PIL import ImageFont, ImageDraw, Image -import os -import subprocess -import requests -import pygetwindow as gw -import pyautogui -import time - -# Nach 2 Sekunden suchen wir das Fenster -time.sleep(2) -for w in gw.getWindowsWithTitle("QR Scanner"): # oder z. B. "pygame", "Loader", etc. - w.activate() - w.maximize() # optional - pyautogui.click(w.left + 100, w.top + 100) # sicherer Klick ins Fenster - break - -API_URL = "https://hag.putschgi.ch/Yb54CO5Ypybx/api/qr.php?action=verify_hash" -API_SECRET = "ddlKC6sgE4EMvsU9VQeIVeYrsPGpE104" - -# Fenstergröße & Farben -window_width, window_height = 1920, 1080 - # Habegger Gold -COLOR_GREEN = (0, 255, 0) -COLOR_YELLOW = (0, 255, 255) -COLOR_BACKGROUND = (45, 46, 47) # HTML-Farbe #2d2e2f -COLOR_WHITE = (255, 255, 255) -COLOR_GOLD = (131, 188, 214) # BGR für Habegger-Gold - -# Font & Logo -FONT_PATH = os.path.join("fonts", "Tisa Sans Pro Regular.ttf" - "") # Alternativ: Georgia.ttf, falls vorhanden -FONT_SIZE = 48 -font = ImageFont.truetype(FONT_PATH, FONT_SIZE) -logo_path = "images/habegger_logo_goldbar_whitefont.png" # Logo mit sichtbarem Text -logo_pil = Image.open(logo_path).convert("RGBA") -logo_pil.thumbnail((300, 100)) -logo_pil = Image.alpha_composite(Image.new("RGBA", logo_pil.size, (0, 0, 0, 0)), logo_pil) -logo_img = np.array(logo_pil) -logo_img = cv2.cvtColor(logo_img, cv2.COLOR_RGBA2BGRA) - -def draw_unicode_text(cv2_image, text, position, color=COLOR_WHITE): - img_pil = Image.fromarray(cv2_image) - draw = ImageDraw.Draw(img_pil) - draw.text(position, text, font=font, fill=color) - return np.array(img_pil) - -def parse_qr_data(data): - from urllib.parse import urlparse, parse_qs - if "?code=" in data: - parsed = urlparse(data) - query = parse_qs(parsed.query) - code = query.get("code", [""])[0] - return code - return None - - -def start_game(mail, name): - with open("last_user.txt", "w", encoding="utf-8-sig") as f: - f.write(f"{mail}\n{name}") - subprocess.run(["python", "loader_game.py"]) - -# Endlosschleife für den Scanner -while True: - detected_color = COLOR_GOLD - detected_time = None - cap = cv2.VideoCapture(0) - cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) - - # Rahmenfarbe wird später im Loop anhand der Zeit gesetzt - last_display_text = "" - start_timer = None - game_started = False - - while True: - success, frame = cap.read() - if not success: - break - - decoded_objects = decode(frame) - if not decoded_objects: - inverted_frame = cv2.bitwise_not(frame) - decoded_objects = decode(inverted_frame) - - # QR-Status zurücksetzen nach 5 Sek. - if detected_time and time.time() - detected_time >= 5.0: - detected_color = COLOR_GOLD - frame_color = COLOR_GOLD - - if decoded_objects: - detected_time = time.time() - - for obj in decoded_objects: - points = obj.polygon - if len(points) > 4: - hull = cv2.convexHull(np.array([point for point in points], dtype=np.float32)) - points = hull.reshape(-1, 2) - for i in range(len(points)): - pt1 = tuple(points[i]) - pt2 = tuple(points[(i + 1) % len(points)]) - cv2.line(frame, pt1, pt2, COLOR_GREEN, 3) - - data = obj.data.decode("utf-8") - code = parse_qr_data(data) - if not code: - detected_color = COLOR_YELLOW - detected_time = time.time() - last_display_text = "Kein gültiger QR-Code erkannt." - break - - try: - res = requests.post(API_URL, json={"code": code, "auth": API_SECRET}, timeout=5) - print("RAW Response:", res.text) - result = res.json() - - except Exception as e: - detected_color = COLOR_YELLOW - detected_time = time.time() - last_display_text = f"API Fehler: {e}" - break - - if not result.get("valid"): - detected_color = COLOR_YELLOW - last_display_text = "Ungültiger Code." - elif result.get("registered"): - name = result.get("name", "Unbekannt") - detected_color = COLOR_GREEN - last_display_text = f"Hallo {name}!" - start_timer = time.time() - game_started = True - last_mail = code - last_name = name - else: - detected_color = COLOR_YELLOW - last_display_text = "QR gültig, aber nicht registriert." - detected_time = time.time() - break - - if start_timer and time.time() - start_timer >= 5.0: - cap.release() - cv2.destroyAllWindows() - start_game(last_mail, last_name) - break - - display_frame = np.full((window_height, window_width, 3), COLOR_BACKGROUND, dtype=np.uint8) - cam_h, cam_w = frame.shape[:2] - x_offset = (window_width - cam_w) // 2 - y_offset = (window_height - cam_h) // 2 - display_frame[y_offset:y_offset+cam_h, x_offset:x_offset+cam_w] = frame - - display_frame = draw_unicode_text(display_frame, - "Scanne deinen QR-Code um das Spiel zu starten.", - (80, 100), COLOR_WHITE) - - if last_display_text: - display_frame = draw_unicode_text(display_frame, last_display_text, - (80, window_height - 100), COLOR_WHITE) - - # Kamera-Rahmen - if detected_time and time.time() - detected_time < 5.0: - current_color = detected_color - else: - current_color = COLOR_GOLD - - cv2.rectangle(display_frame, (x_offset, y_offset), - (x_offset + cam_w, y_offset + cam_h), current_color, 6) - - # Logo oben rechts ohne Alpha-Blending-Probleme - logo_pos = (window_width - logo_img.shape[1] - 40, 40) - overlay = np.zeros_like(display_frame, dtype=np.uint8) - overlay[logo_pos[1]:logo_pos[1]+logo_img.shape[0], logo_pos[0]:logo_pos[0]+logo_img.shape[1]] = logo_img[:, :, :3] - mask = logo_img[:, :, 3] # alpha channel - for c in range(3): - display_frame[logo_pos[1]:logo_pos[1]+logo_img.shape[0], logo_pos[0]:logo_pos[0]+logo_img.shape[1], c] = ( - logo_img[:, :, c] * (mask / 255.0) + display_frame[logo_pos[1]:logo_pos[1]+logo_img.shape[0], logo_pos[0]:logo_pos[0]+logo_img.shape[1], c] * (1.0 - mask / 255.0)).astype(np.uint8) - - cv2.namedWindow("QR Scanner", cv2.WINDOW_NORMAL) - cv2.setWindowProperty("QR Scanner", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) - cv2.imshow("QR Scanner", display_frame) - - key = cv2.waitKey(1) - if key == 27 or key == ord('q'): - cap.release() - cv2.destroyAllWindows() - exit() diff --git a/qr_scan_old.py b/qr_scan_old.py deleted file mode 100644 index fa2c5d6..0000000 --- a/qr_scan_old.py +++ /dev/null @@ -1,160 +0,0 @@ -import cv2 -from pyzbar.pyzbar import decode -import time -import numpy as np -from urllib.parse import urlparse, parse_qs, unquote -from PIL import ImageFont, ImageDraw, Image -import os -import subprocess - -# Fenstergröße & Farben -window_width, window_height = 1920, 1080 - # Habegger Gold -COLOR_GREEN = (0, 255, 0) -COLOR_YELLOW = (0, 255, 255) -COLOR_BACKGROUND = (45, 46, 47) # HTML-Farbe #2d2e2f -COLOR_WHITE = (255, 255, 255) -COLOR_GOLD = (131, 188, 214) # BGR für Habegger-Gold - -# Font & Logo -FONT_PATH = os.path.join("fonts", "Tisa Sans Pro Regular.ttf" - "") # Alternativ: Georgia.ttf, falls vorhanden -FONT_SIZE = 48 -font = ImageFont.truetype(FONT_PATH, FONT_SIZE) -logo_path = "images/habegger_logo_goldbar_whitefont.png" # Logo mit sichtbarem Text -logo_pil = Image.open(logo_path).convert("RGBA") -logo_pil.thumbnail((300, 100)) -logo_pil = Image.alpha_composite(Image.new("RGBA", logo_pil.size, (0, 0, 0, 0)), logo_pil) -logo_img = np.array(logo_pil) -logo_img = cv2.cvtColor(logo_img, cv2.COLOR_RGBA2BGRA) - -def draw_unicode_text(cv2_image, text, position, color=COLOR_WHITE): - img_pil = Image.fromarray(cv2_image) - draw = ImageDraw.Draw(img_pil) - draw.text(position, text, font=font, fill=color) - return np.array(img_pil) - -def parse_qr_data(data): - try: - parsed = urlparse(data) - query = parse_qs(parsed.query) - mail = query.get("mail", [""])[0] - name = query.get("name", [""])[0] - name = unquote(name, encoding="utf-8", errors="replace") - name = (name.replace("鐼", "ö") - .replace("ü", "ü") - .replace("ä", "ä") - .replace("Ö", "Ö") - .replace("Ü", "Ü") - .replace("Ãß", "ß")) - if "@habegger.ch" in mail.lower() and name.strip(): - return mail.strip(), name.strip() - except: - pass - return None, None - -def start_game(mail, name): - with open("last_user.txt", "w", encoding="utf-8-sig") as f: - f.write(f"{mail}\n{name}") - subprocess.run(["python", "loader_game.py"]) - -# Endlosschleife für den Scanner -while True: - detected_color = COLOR_GOLD - detected_time = None - cap = cv2.VideoCapture(0) - cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) - cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) - - # Rahmenfarbe wird später im Loop anhand der Zeit gesetzt - last_display_text = "" - start_timer = None - game_started = False - - while True: - success, frame = cap.read() - if not success: - break - - decoded_objects = decode(frame) - # QR-Status zurücksetzen nach 5 Sek. - if detected_time and time.time() - detected_time >= 5.0: - detected_color = COLOR_GOLD - frame_color = COLOR_GOLD - - if decoded_objects: - detected_time = time.time() - for obj in decoded_objects: - points = obj.polygon - if len(points) > 4: - hull = cv2.convexHull(np.array([point for point in points], dtype=np.float32)) - points = hull.reshape(-1, 2) - for i in range(len(points)): - pt1 = tuple(points[i]) - pt2 = tuple(points[(i + 1) % len(points)]) - cv2.line(frame, pt1, pt2, COLOR_GREEN, 3) - - data = obj.data.decode("utf-8") - mail, name = parse_qr_data(data) - - if mail and name: - detected_color = COLOR_GREEN - last_display_text = f"Richtiger QR-Code: Hallo {name}" - detected_time = time.time() - start_timer = time.time() - game_started = True - last_mail = mail - last_name = name - else: - detected_color = COLOR_YELLOW - detected_time = time.time() - last_display_text = "Bitte dein HAG Last-Thursday Code scannen." - break - - if start_timer and time.time() - start_timer >= 5.0: - cap.release() - cv2.destroyAllWindows() - start_game(last_mail, last_name) - break - - display_frame = np.full((window_height, window_width, 3), COLOR_BACKGROUND, dtype=np.uint8) - cam_h, cam_w = frame.shape[:2] - x_offset = (window_width - cam_w) // 2 - y_offset = (window_height - cam_h) // 2 - display_frame[y_offset:y_offset+cam_h, x_offset:x_offset+cam_w] = frame - - display_frame = draw_unicode_text(display_frame, - "Scanne deinen QR-Code um das Spiel zu starten.", - (80, 100), COLOR_WHITE) - - if last_display_text: - display_frame = draw_unicode_text(display_frame, last_display_text, - (80, window_height - 100), COLOR_WHITE) - - # Kamera-Rahmen - if detected_time and time.time() - detected_time < 5.0: - current_color = detected_color - else: - current_color = COLOR_GOLD - - cv2.rectangle(display_frame, (x_offset, y_offset), - (x_offset + cam_w, y_offset + cam_h), current_color, 6) - - # Logo oben rechts ohne Alpha-Blending-Probleme - logo_pos = (window_width - logo_img.shape[1] - 40, 40) - overlay = np.zeros_like(display_frame, dtype=np.uint8) - overlay[logo_pos[1]:logo_pos[1]+logo_img.shape[0], logo_pos[0]:logo_pos[0]+logo_img.shape[1]] = logo_img[:, :, :3] - mask = logo_img[:, :, 3] # alpha channel - for c in range(3): - display_frame[logo_pos[1]:logo_pos[1]+logo_img.shape[0], logo_pos[0]:logo_pos[0]+logo_img.shape[1], c] = ( - logo_img[:, :, c] * (mask / 255.0) + display_frame[logo_pos[1]:logo_pos[1]+logo_img.shape[0], logo_pos[0]:logo_pos[0]+logo_img.shape[1], c] * (1.0 - mask / 255.0)).astype(np.uint8) - - cv2.namedWindow("QR Scanner", cv2.WINDOW_NORMAL) - cv2.setWindowProperty("QR Scanner", cv2.WND_PROP_FULLSCREEN, cv2.WINDOW_FULLSCREEN) - cv2.imshow("QR Scanner", display_frame) - - key = cv2.waitKey(1) - if key == 27 or key == ord('q'): - cap.release() - cv2.destroyAllWindows() - exit() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..19f0d9b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# Laufzeitabhängigkeiten des Habegger Loader Games. +# Bewusst schlank gehalten: nur pygame (inkl. SDL2-Wheels für macOS/Windows/Linux). +pygame>=2.6,<3 diff --git a/run.command b/run.command new file mode 100755 index 0000000..982c365 --- /dev/null +++ b/run.command @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# macOS: Dieses Skript im Finder doppelklicken startet das Spiel. +# (Beim ersten Mal ggf. Rechtsklick → Öffnen, wegen Gatekeeper.) +cd "$(dirname "$0")" || exit 1 +exec ./run.sh "$@" diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..06e2874 --- /dev/null +++ b/run.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Startet das Habegger Loader Game. +# +# Beim ersten Start wird eine lokale virtuelle Umgebung (.venv) angelegt und +# requirements.txt installiert. Ändert sich requirements.txt, wird neu +# installiert. Alle Argumente werden an main.py weitergegeben, z. B.: +# +# ./run.sh --windowed +# ./run.sh --name "Anna" --seed 42 +# +# Getestet auf macOS (arm64) und Linux. +set -euo pipefail +cd "$(dirname "$0")" + +VENV=".venv" +PYTHON="${PYTHON:-python3}" +STAMP="$VENV/.requirements.sha256" + +if [ ! -x "$VENV/bin/python" ]; then + echo "→ Lege virtuelle Umgebung an ($("$PYTHON" --version 2>&1))" + "$PYTHON" -m venv "$VENV" +fi + +if command -v shasum >/dev/null 2>&1; then + REQ_HASH="$(shasum -a 256 requirements.txt | cut -d' ' -f1)" +else + REQ_HASH="$(sha256sum requirements.txt | cut -d' ' -f1)" +fi + +if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$REQ_HASH" ]; then + echo "→ Installiere Abhängigkeiten aus requirements.txt" + "$VENV/bin/python" -m pip install --quiet --upgrade pip + "$VENV/bin/python" -m pip install --quiet -r requirements.txt + echo "$REQ_HASH" >"$STAMP" +fi + +exec "$VENV/bin/python" main.py "$@" diff --git a/tests/test_logic.py b/tests/test_logic.py new file mode 100644 index 0000000..d81a02a --- /dev/null +++ b/tests/test_logic.py @@ -0,0 +1,349 @@ +"""Tests für die Spiellogik (ohne Fenster, ohne Ton). + +Aufruf:: + + .venv/bin/python -m unittest discover -s tests -v +""" + +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.case import Case +from loader_game.game import Game, State +from loader_game.input_map import Action +from loader_game.sequences import CaseSpec, load_sequences, parse_sequences +from loader_game.trailer import Trailer + + +class SequenceTests(unittest.TestCase): + def test_real_file_parses_into_six_sequences(self) -> None: + sequences, warnings = load_sequences() + self.assertEqual(warnings, []) + self.assertEqual(len(sequences), 6) + self.assertEqual(sequences[0][0], CaseSpec(3, 1)) + self.assertEqual(sequences[0][3], CaseSpec(2, 1)) # aus "1,2,True" + + def test_rotated_flag_swaps_dimensions(self) -> None: + sequences, _ = parse_sequences("1,3,True\n2,1,False") + self.assertEqual(sequences, [[CaseSpec(3, 1), CaseSpec(2, 1)]]) + + def test_blank_lines_separate_sequences(self) -> None: + sequences, _ = parse_sequences("1,1,False\n\n2,2,False\n\n") + self.assertEqual(len(sequences), 2) + + def test_invalid_lines_warn_and_are_skipped(self) -> None: + sequences, warnings = parse_sequences("1,1\n1,1,False,extra\n0,1,False\n2,2,False") + self.assertEqual(sequences, [[CaseSpec(2, 2)]]) + self.assertEqual(len(warnings), 3) + + def test_garbage_falls_back_to_default(self) -> None: + sequences, warnings = parse_sequences("nur müll") + self.assertEqual(sequences, []) + # eine Meldung für die kaputte Zeile, eine für die fehlende Sequenz + self.assertEqual(len(warnings), 2) + + def test_all_specs_fit_available_images(self) -> None: + sequences, _ = load_sequences() + for specs in sequences: + for spec in specs: + self.assertLessEqual(spec.units_w, config.MAX_UNITS_W, spec) + self.assertLessEqual(spec.units_h, config.MAX_UNITS_H, spec) + + +class LayoutTests(unittest.TestCase): + def test_bands_add_up_to_screen_height(self) -> None: + self.assertEqual( + config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT + config.BOTTOM_BAR_HEIGHT, + config.SCREEN_HEIGHT, + ) + + def test_trailer_fits_into_game_area(self) -> None: + self.assertGreaterEqual(config.TRAILER_X, 0) + self.assertLessEqual(config.TRAILER_RIGHT, config.SCREEN_WIDTH) + self.assertGreaterEqual(config.TRAILER_Y, config.TOP_BAR_HEIGHT) + self.assertLessEqual( + config.TRAILER_BOTTOM, config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT + ) + + def test_case_unit_is_square(self) -> None: + self.assertEqual(config.UNIT_LENGTH, config.UNIT_HEIGHT) + + +class CaseTests(unittest.TestCase): + def make_case(self, units_w: int = 2, units_h: int = 1, x: float | None = None) -> Case: + return Case( + units_w=units_w, + units_h=units_h, + x=float(config.SCREEN_WIDTH if x is None else x), + y=float(config.TRAILER_BOTTOM - units_h * config.UNIT_HEIGHT), + ) + + def test_moves_left_and_brakes_near_wall(self) -> None: + case = self.make_case() + start = case.x + case.move(1 / 60, wall_x=0) + fast_step = start - case.x + self.assertGreater(fast_step, 0) + + case.x = float(config.FAST_TO_SLOW_DISTANCE - 20) # schon im Bremsbereich + before = case.x + case.move(1 / 60, wall_x=0) + slow_step = before - case.x + self.assertLess(slow_step, fast_step) + + def test_allow_snap_only_near_wall(self) -> None: + case = self.make_case() + case.move(1 / 60, wall_x=0) + self.assertFalse(case.allow_snap) + + case = self.make_case(x=config.SNAP_THRESHOLD + 2) + case.move(1 / 60, wall_x=0) + self.assertTrue(case.allow_snap) + + def test_placed_case_does_not_move(self) -> None: + case = self.make_case() + case.placed = True + start = case.x + 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)) + + wide = self.make_case(units_w=4, units_h=1) + self.assertFalse(wide.fits_trailer_height()) + + +class TrailerTests(unittest.TestCase): + WALL = config.TRAILER_X + 500 + + def setUp(self) -> None: + self.trailer = Trailer() + + def make_case(self, units_w: int, units_h: int) -> Case: + return Case( + units_w=units_w, + units_h=units_h, + x=float(config.TRAILER_RIGHT), + y=float(config.TRAILER_BOTTOM - units_h * config.UNIT_HEIGHT), + ) + + def add_base(self, units_w: int = 4, units_h: int = 1, y: float | None = None) -> Case: + """Schwebender Koffer, rechtsbündig an der Barriere.""" + base = Case( + units_w=units_w, + units_h=units_h, + x=0.0, + y=float(config.TRAILER_Y + 100 if y is None else y), + ) + base.x = self.WALL - base.width + base.placed = True + base.snapped = True + self.trailer.stack.append(base) + self.trailer.wall_x = base.right + return base + + # ------------------------------------------------------------------ + def test_empty_trailer(self) -> None: + self.assertEqual(self.trailer.wall_x, self.trailer.x) + self.assertEqual(self.trailer.load_percent(), 0.0) + self.assertFalse(self.trailer.overflow()) + self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1))) + + def test_snap_advances_wall_and_load(self) -> None: + case = self.make_case(4, 1) + self.trailer.stack_at_wall(case) + self.assertTrue(case.placed) + self.assertTrue(case.snapped) + self.assertEqual(self.trailer.wall_x, self.trailer.x + case.width) + expected = ( + case.width * case.height / (self.trailer.width * self.trailer.height) * 100 + ) + self.assertAlmostEqual(self.trailer.load_percent(), expected) + + def test_gap_placement_leaves_hole(self) -> None: + case = self.make_case(2, 1) + self.trailer.stack_with_gap(case) + self.assertEqual(case.x, self.trailer.x + config.SNAP_THRESHOLD) + self.assertFalse(case.snapped) + + def test_platform_on_flat_ground_case_is_allowed(self) -> None: + # 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. + ground = self.make_case(2, 3) + self.trailer.stack_at_wall(ground) + self.assertEqual(ground.y, config.TRAILER_Y) + self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1))) + + def test_platform_found_for_free_top(self) -> None: + base = self.add_base(units_w=4, units_h=1) + self.assertIs(self.trailer.find_platform(self.make_case(1, 1)), base) + + def test_platform_rejects_too_wide_case(self) -> None: + self.add_base(units_w=2, units_h=1) + self.assertIsNone(self.trailer.find_platform(self.make_case(3, 1))) + + def test_platform_rejects_case_not_aligned_with_wall(self) -> None: + base = self.add_base(units_w=4, units_h=1) + base.x -= 50 # nicht mehr bündig an der Barriere + self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1))) + + def test_platform_rejects_no_space_above(self) -> None: + self.add_base(units_w=4, units_h=1, y=config.TRAILER_Y + 50) + self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1))) + + def test_top_can_only_be_used_once(self) -> None: + base = self.add_base(units_w=4, units_h=1) + top = self.make_case(1, 1) + self.trailer.place_on_top(top, base) + self.assertEqual(top.bottom, base.y) + self.assertEqual(top.right, base.right) + self.assertIsNone(self.trailer.find_platform(self.make_case(1, 1))) + + def test_overflow(self) -> None: + self.trailer.wall_x = self.trailer.right + 1 + self.assertTrue(self.trailer.overflow()) + + +class GameTests(unittest.TestCase): + def make_game(self, sequence_length: int = 30) -> Game: + sequences = [[CaseSpec(2, 1)] * sequence_length] + return Game(None, sequences, name="Test", rng=random.Random(7), submit_scores=False) + + def update_until(self, game: Game, predicate, steps: int = 5000) -> bool: + for _ in range(steps): + if predicate(): + return True + game.update(1 / 60) + return predicate() + + def test_starts_with_a_case(self) -> None: + game = self.make_game() + self.assertIsNotNone(game.current) + self.assertEqual(game.index, 1) + self.assertEqual(len(game.upcoming()), 3) + + def test_snapping_places_case_and_advances_wall(self) -> None: + game = self.make_game() + self.assertTrue( + self.update_until(game, lambda: bool(game.current and game.current.allow_snap)) + ) + game.handle(Action.PRIMARY) + self.update_until(game, lambda: game.animation is None) + self.assertEqual(len(game.trailer.stack), 1) + self.assertAlmostEqual( + game.trailer.wall_x, config.TRAILER_X + 2 * config.UNIT_LENGTH + ) + + def test_rotation_turns_the_case(self) -> None: + game = self.make_game() + 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.assertFalse(case.can_rotate) + + def test_rotation_ignored_for_wide_cases(self) -> None: + game = Game( + None, + [[CaseSpec(4, 1)] * 5], + name="Test", + rng=random.Random(1), + submit_scores=False, + ) + case = game.current + case.allow_snap = True + game.handle(Action.LEFT) + self.assertIsNone(game.animation) + self.assertEqual((case.units_w, case.units_h), (4, 1)) + + def test_missing_platform_stacks_with_gap(self) -> None: + game = self.make_game() + self.assertTrue( + self.update_until(game, lambda: bool(game.current and game.current.allow_snap)) + ) + game.handle(Action.UP) # es gibt nichts, worauf man stapeln könnte + self.assertEqual(len(game.trailer.stack), 1) + self.assertFalse(game.trailer.stack[0].snapped) + self.assertGreater(game.shake, 0) + + 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.assertGreater(game.percent, 0) + self.assertLessEqual(game.percent, 100) + + 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: + 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.assertEqual(len(game.trailer.stack), 3) + + def test_back_quits_and_primary_confirms_game_over(self) -> None: + game = self.make_game(sequence_length=2) + self.assertTrue(game.handle(Action.PRIMARY)) + self.assertFalse(game.handle(Action.BACK)) + + 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]) + + +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"] + + 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"] + + +if __name__ == "__main__": + unittest.main()