Altstand nach old_version/ eingefroren (untracked). Monolith ersetzt durch main.py + Paket loader_game/. Neu: run.sh/run.command mit venv+requirements, .gitignore, docs/ANALYSE.md, 35 Tests. Secrets entfernt. Gelöscht: loader_game.py, qr_scan*.py, INSTALL_README.txt.
This commit is contained in:
1 parent
6a2c891c3e
commit
de550e1d4d
24 files changed
+2269
-1008
No files matched your search
+16
@@ -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
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
python.exe -m pip install --upgrade pip
|
|
||||||
|
|
||||||
pip install pillow pyzbar opencv-python numpy pygame pygetwindow requests pyautogui
|
|
||||||
@@ -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.
|
||||||
@@ -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).
|
||||||
-656
@@ -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()
|
|
||||||
@@ -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"
|
||||||
@@ -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())
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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."
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
-188
@@ -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()
|
|
||||||
-160
@@ -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()
|
|
||||||
@@ -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
|
||||||
Executable
+5
@@ -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 "$@"
|
||||||
@@ -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 "$@"
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user