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:
Project Bridge committed 2026-11-01 10:25:13 +00:00
1 parent 6a2c891c3e
commit de550e1d4d
24 files changed
+2269 -1008

No files matched your search

+16
View File
@@ -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
-3
View File
@@ -1,3 +0,0 @@
python.exe -m pip install --upgrade pip
pip install pillow pyzbar opencv-python numpy pygame pygetwindow requests pyautogui
+130 -1
View File
@@ -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.
+84
View File
@@ -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
View File
@@ -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()
+18
View File
@@ -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"
+176
View File
@@ -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())
+71
View File
@@ -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
+151
View File
@@ -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
+333
View File
@@ -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()
+108
View File
@@ -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",
]
+82
View File
@@ -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),
)
+47
View File
@@ -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."
+205
View File
@@ -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),
)
+107
View File
@@ -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
+50
View File
@@ -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
+124
View File
@@ -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)
Executable
+173
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+3
View File
@@ -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
View File
@@ -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 "$@"
Executable
+37
View File
@@ -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 "$@"
+349
View File
@@ -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()