v1.1: Stufen Easy/Normal/Hard mit Auswahlmenü (Hard = altes Tempo). Koffer behalten beim Drehen ihre Textur. Name/Punkteliste entfernt, nur Stufe + Prozent. Endlos: Ergebnis -> "Nochmals?" -> Stufenauswahl, Ende nur per Esc. storage.py und net.py gelöscht, 48 Tests.
This commit is contained in:
1 parent
de550e1d4d
commit
d970185e75
15 files changed
+613
-342
No files matched your search
+2
-2
@@ -6,8 +6,8 @@ old_version/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|
||||||
# Laufzeitdaten (Spielername, lokale Ergebnisse, Logs)
|
# Lokale Logs
|
||||||
runtime/
|
# (keine Laufzeitdaten mehr)
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Editor- und OS-Kram
|
# Editor- und OS-Kram
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Habegger Trailer Load
|
# Habegger Trailer Load
|
||||||
|
|
||||||
Kofferlager-Spiel für Messe-/Eventbetrieb: Koffer laufen von rechts ein und
|
Kofferlager-Spiel für Messe-/Eventbetrieb: Koffer laufen von rechts ein und
|
||||||
müssen im Auflieger möglichst dicht gestapelt werden. Ziel ist eine hohe
|
müssen im Auflieger möglichst dicht gestapelt werden. Angezeigt wird am Ende
|
||||||
Beladung in Prozent.
|
die Stufe und die erreichte Beladung in Prozent.
|
||||||
|
|
||||||
Neu aufgesetzt für macOS (Apple Silicon) mit Controller-Unterstützung.
|
Neu aufgesetzt für macOS (Apple Silicon) mit Controller-Unterstützung.
|
||||||
Die Analyse des Altstands und die Liste der behobenen Fehler stehen in
|
Die Analyse des Altstands und die Liste der behobenen Fehler stehen in
|
||||||
@@ -27,68 +27,67 @@ automatisch. Ändert sich `requirements.txt`, wird neu installiert.
|
|||||||
Auf macOS lässt sich das Spiel auch per Doppelklick auf **`run.command`**
|
Auf macOS lässt sich das Spiel auch per Doppelklick auf **`run.command`**
|
||||||
starten (beim ersten Mal ggf. Rechtsklick → Öffnen wegen Gatekeeper).
|
starten (beim ersten Mal ggf. Rechtsklick → Öffnen wegen Gatekeeper).
|
||||||
|
|
||||||
Optionen:
|
|
||||||
|
|
||||||
| Option | Bedeutung |
|
| Option | Bedeutung |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `--fullscreen` | Vollbild starten (F11 schaltet jederzeit um) |
|
| `--fullscreen` | Vollbild starten (F11 schaltet jederzeit um) |
|
||||||
| `--name "Anna"` | Spielername setzen (sonst der zuletzt gespeicherte) |
|
|
||||||
| `--seed 42` | feste Zufallsfolge, reproduzierbare Koffer-Sequenzen |
|
| `--seed 42` | feste Zufallsfolge, reproduzierbare Koffer-Sequenzen |
|
||||||
| `--smoke 10` | Selbsttest: bestätigt das Intro automatisch, spielt 10 s ohne Eingaben und beendet sich |
|
| `--smoke 10` | Selbsttest: spielt 10 s ohne Eingaben und beendet sich |
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
1. **Tutorialbild** (nur beim Programmstart) – weiter mit A / Space
|
||||||
|
2. **Stufenauswahl**: Easy / Normal / Hard (Hard = Tempo des Altstands)
|
||||||
|
3. **Ampel-Start** (rot/gelb/grün + Reissverschluss), dann läuft die Runde
|
||||||
|
4. **Ergebnis**: Stufe und Beladung in Prozent, „Nochmals?" → A startet neu
|
||||||
|
bei der Stufenauswahl
|
||||||
|
|
||||||
|
Beendet wird ausschliesslich mit **Esc** (Controller: B).
|
||||||
|
|
||||||
## Steuerung
|
## Steuerung
|
||||||
|
|
||||||
| Aktion | Tastatur | Controller (Xbox-Layout) |
|
| Aktion | Tastatur | Controller (Xbox-Layout) |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Einrasten an der Barriere | `Space` / `Enter` | A |
|
| Stufe wählen | `←` / `→` | D-Pad links/rechts |
|
||||||
|
| Bestätigen / Einrasten an der Barriere | `Space` / `Enter` | A |
|
||||||
| Koffer drehen (wenn er passt) | `←` | X / D-Pad links |
|
| Koffer drehen (wenn er passt) | `←` | X / D-Pad links |
|
||||||
| Auf einen Koffer obendrauf setzen | `↑` | Y / D-Pad hoch |
|
| Auf einen Koffer obendrauf setzen | `↑` | Y / D-Pad hoch |
|
||||||
| Spiel beenden | `Esc` | B |
|
| Beenden | `Esc` | B |
|
||||||
| Vollbild umschalten | `F11` | – |
|
| Vollbild umschalten | `F11` | – |
|
||||||
|
|
||||||
Stick und D-Pad werden beide ausgewertet, Controller-Hotplug funktioniert
|
Ein gedrehter Koffer behält seine Grafik – er wird nur um 90° gedreht
|
||||||
(der Name des erkannten Controllers steht beim Start im Terminal).
|
gezeichnet, nie durch eine andere Textur ersetzt.
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
main.py Einstiegspunkt (Fenster, Hauptschleife, CLI)
|
main.py Einstiegspunkt (Phasen: Intro → Menü → Runde)
|
||||||
run.sh / run.command Start inkl. venv + Installation
|
run.sh / run.command Start inkl. venv + Installation
|
||||||
requirements.txt pygame
|
requirements.txt pygame
|
||||||
case_sequences.txt Koffersequenzen (Datenbestand)
|
case_sequences.txt Koffersequenzen (Datenbestand)
|
||||||
loader_game/
|
loader_game/
|
||||||
config.py Masse, Tempi, Farben, Texte, Pfade
|
config.py Masse, Farben, Texte, Pfade
|
||||||
|
difficulty.py Stufen Easy/Normal/Hard (Tempo)
|
||||||
assets.py Bilder/Sounds/Fonts laden und cachen
|
assets.py Bilder/Sounds/Fonts laden und cachen
|
||||||
sequences.py Einlesen und Validieren der Sequenzdatei
|
sequences.py Einlesen und Validieren der Sequenzdatei
|
||||||
case.py Koffer: Geometrie und Einlaufbewegung
|
case.py Koffer: Geometrie, Textur, Bewegung
|
||||||
trailer.py Stapelregeln im Auflieger (reine Logik)
|
trailer.py Stapelregeln im Auflieger (reine Logik)
|
||||||
input_map.py Tastatur/Controller → Action
|
input_map.py Tastatur/Controller → Action
|
||||||
scene.py Zeichnen
|
scene.py Zeichnen
|
||||||
|
menu.py Stufenauswahl
|
||||||
intro.py Tutorialbild, Ampel, Reissverschluss
|
intro.py Tutorialbild, Ampel, Reissverschluss
|
||||||
game.py Zustandsmaschine des Spiels
|
game.py Zustandsmaschine einer Runde
|
||||||
storage.py Laufzeitdaten (letzter Spielername)
|
tests/test_logic.py 48 Tests (ohne Fenster)
|
||||||
net.py optionaler Punkte-Upload
|
|
||||||
tests/test_logic.py 35 Tests (ohne Fenster)
|
|
||||||
docs/ANALYSE.md Analyse Altstand + Fehlerliste
|
docs/ANALYSE.md Analyse Altstand + Fehlerliste
|
||||||
old_version/ eingefrorener Altstand (nicht versioniert)
|
old_version/ eingefrorener Altstand (nicht versioniert)
|
||||||
runtime/ Laufzeitdaten (nicht versioniert)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Alle Werte stehen in `loader_game/config.py` – Auflösung, Bänder, Auflieger,
|
Alle Werte stehen in `loader_game/config.py` (Layout, Farben, Texte, Tempo der
|
||||||
Tempo, Farben, Texte. Gezeichnet wird immer in der virtuellen Auflösung
|
Animationen) und `loader_game/difficulty.py` (Einlaufgeschwindigkeit der drei
|
||||||
1920×1080; `pygame.SCALED` skaliert automatisch auf die tatsächliche
|
Stufen). Gezeichnet wird immer in der virtuellen Auflösung 1920×1080;
|
||||||
Fenstergrösse (Retina, 1440×900, 4K …).
|
`pygame.SCALED` skaliert automatisch auf die tatsächliche Fenstergrösse
|
||||||
|
(Retina, 1440×900, 4K …).
|
||||||
Punkte-Upload (standardmässig aus):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export HAG_SCORE_URL="https://…/api/submit_points.php"
|
|
||||||
export HAG_SCORE_API_KEY="…"
|
|
||||||
```
|
|
||||||
|
|
||||||
Zugangsdaten gehören in die Umgebung, **nicht** in den Quellcode.
|
|
||||||
|
|
||||||
## Sequenzdatei
|
## Sequenzdatei
|
||||||
|
|
||||||
@@ -109,23 +108,19 @@ Ungültige Zeilen werden beim Start mit Meldung übersprungen.
|
|||||||
.venv/bin/python -m unittest discover -s tests -v
|
.venv/bin/python -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Getestet werden Sequenz-Parsing, Layout-Invarianten, Kofferbewegung,
|
Getestet werden Sequenz-Parsing, Layout-Invarianten, Kofferbewegung
|
||||||
Stapelregeln, komplette Spieldurchläufe (bis Spielende) und die
|
(inkl. Stufen-Tempo), Stapelregeln, Menü-Navigation, komplette Spieldurchläufe
|
||||||
Laufzeitdaten. Kein Fenster, kein Ton nötig.
|
bis zum Ergebnis sowie das Rendern von Intro, Menü, Spiel und Ergebnis.
|
||||||
|
Kein Fenster, kein Ton nötig.
|
||||||
## Laufzeitdaten
|
|
||||||
|
|
||||||
`runtime/` (in `.gitignore`): zuletzt verwendeter Spielername. Per
|
|
||||||
`HAG_RUNTIME_DIR` umlenkbar (nutzen die Tests).
|
|
||||||
|
|
||||||
## Eingefrorener Altstand
|
## Eingefrorener Altstand
|
||||||
|
|
||||||
`old_version/` enthält die Dateien vor dem Umbau – ebenfalls in `.gitignore`,
|
`old_version/` enthält die Dateien vor dem Umbau – in `.gitignore`, also
|
||||||
also **nicht** versioniert. Details: `old_version/FROZEN.md`.
|
**nicht** versioniert. Details: `old_version/FROZEN.md`.
|
||||||
|
|
||||||
## Nächste Schritte
|
## Nächste Schritte
|
||||||
|
|
||||||
1. Namenseingabe direkt am Controller (Buchstabe für Buchstabe, alternativ
|
1. Namenseingabe direkt am Controller (Buchstabe für Buchstabe, alternativ
|
||||||
Tastatur) – ersetzt QR-Scanner und `last_user.txt`.
|
Tastatur) – falls wieder eine Personalisierung gewünscht ist.
|
||||||
2. Mehrere Durchgänge ohne Neustart des Programms (zurück zur Namenseingabe).
|
2. Entscheiden, ob und wohin Ergebnisse gesendet werden (Punkteliste ist
|
||||||
3. Entscheiden, ob und wohin die Punkte gesendet werden.
|
derzeit bewusst nicht vorhanden).
|
||||||
@@ -82,3 +82,15 @@ qr_scan.py ──► last_user.txt ──► subprocess: python loader_game.
|
|||||||
3. Entscheiden, ob der Punkte-Upload wieder aktiv genutzt wird – und wohin.
|
3. Entscheiden, ob der Punkte-Upload wieder aktiv genutzt wird – und wohin.
|
||||||
4. `images/*.png` haben ein defektes sRGB-Profil (libpng-Warnung beim Laden,
|
4. `images/*.png` haben ein defektes sRGB-Profil (libpng-Warnung beim Laden,
|
||||||
rein kosmetisch).
|
rein kosmetisch).
|
||||||
|
|
||||||
|
## 6. Änderungen nach dem ersten Test (v1.1)
|
||||||
|
|
||||||
|
Rückmeldungen aus dem ersten Durchlauf auf dem MacBook und was daraus wurde:
|
||||||
|
|
||||||
|
| Rückmeldung | Umsetzung |
|
||||||
|
| --- | --- |
|
||||||
|
| „läuft viel zu schnell" | Stufen **Easy / Normal / Hard** (`difficulty.py`), Hard entspricht exakt dem Altstand-Tempo (720/240 px pro Sekunde). Auswahl im Startmenü mit `←`/`→` und A/Space; das Tempo steht jetzt an einer Stelle. |
|
||||||
|
| „beim Tippen plötzlich andere Textur" | Der Koffer merkt sich in `Case.texture` die Orientierung, in der er eingelaufen ist. Gedreht wird nur die Grafik (90°), nie gegen eine andere Bilddatei getauscht. |
|
||||||
|
| „Spieler klingt doof" | Name und Punkteliste komplett entfernt (`storage.py`, `net.py`, `--name` gelöscht). Angezeigt werden nur noch Stufe und Beladung in Prozent. |
|
||||||
|
| „soll nicht enden, sondern fragen: nochmals?" | Neue Phasenkette in `main.py`: Tutorial → Menü → Ampel → Runde → Ergebnis → Menü → … Beendet wird nur mit `Esc`/B. `Game.handle()` liefert dafür `HandleResult.CONTINUE / NEW_ROUND / QUIT`. |
|
||||||
|
| „lädst du von einer Map/CSV?" | Ja: `case_sequences.txt` im Projektwurzelverzeichnis, 6 Sequenzen als `breite,höhe,rotiert`. Pro Runde wird zufällig eine Sequenz gezogen. |
|
||||||
+12
-12
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
Aufbau (jedes Modul hat genau eine Aufgabe):
|
Aufbau (jedes Modul hat genau eine Aufgabe):
|
||||||
|
|
||||||
* :mod:`loader_game.config` – alle Masse, Farben, Tempi, Pfade
|
* :mod:`loader_game.config` – alle Masse, Farben, Texte, Pfade
|
||||||
* :mod:`loader_game.assets` – Bilder, Sounds, Fonts laden und cachen
|
* :mod:`loader_game.difficulty` – Stufen Easy/Normal/Hard (Tempo)
|
||||||
* :mod:`loader_game.sequences` – Einlesen von ``case_sequences.txt``
|
* :mod:`loader_game.assets` – Bilder, Sounds, Fonts laden und cachen
|
||||||
* :mod:`loader_game.case` – der Koffer (Geometrie und Bewegung)
|
* :mod:`loader_game.sequences` – Einlesen von ``case_sequences.txt``
|
||||||
* :mod:`loader_game.trailer` – Stapelregeln im Auflieger (reine Logik)
|
* :mod:`loader_game.case` – der Koffer (Geometrie, Textur, Bewegung)
|
||||||
* :mod:`loader_game.input_map` – Tastatur/Controller → abstrakte Aktionen
|
* :mod:`loader_game.trailer` – Stapelregeln im Auflieger (reine Logik)
|
||||||
* :mod:`loader_game.scene` – Zeichnen
|
* :mod:`loader_game.input_map` – Tastatur/Controller → abstrakte Aktionen
|
||||||
* :mod:`loader_game.intro` – Tutorialbild + Ampel + Reissverschluss
|
* :mod:`loader_game.scene` – Zeichnen
|
||||||
* :mod:`loader_game.game` – Zustandsmaschine des Spiels
|
* :mod:`loader_game.menu` – Stufenauswahl
|
||||||
* :mod:`loader_game.storage` – Laufzeitdaten (letzter Spielername)
|
* :mod:`loader_game.intro` – Tutorialbild + Ampel + Reissverschluss
|
||||||
* :mod:`loader_game.net` – optionaler Punkte-Upload
|
* :mod:`loader_game.game` – Zustandsmaschine einer Runde
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
+24
-6
@@ -8,6 +8,7 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from . import config
|
from . import config
|
||||||
|
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -15,9 +16,11 @@ class Case:
|
|||||||
"""Ein Koffer im Auflieger.
|
"""Ein Koffer im Auflieger.
|
||||||
|
|
||||||
``units_w`` / ``units_h`` beschreiben die **aktuelle** Grundfläche in
|
``units_w`` / ``units_h`` beschreiben die **aktuelle** Grundfläche in
|
||||||
Kasten-Einheiten. Eine Drehung tauscht die beiden Werte – es gibt also
|
Kasten-Einheiten. Eine Drehung tauscht die beiden Werte.
|
||||||
keinen zusätzlichen ``rotated``-Schalter, der mit den Massen kollidieren
|
|
||||||
könnte (das war im Altstand eine Fehlerquelle).
|
``texture`` merkt sich die Orientierung, in der der Koffer eingelaufen ist.
|
||||||
|
Die Grafik bleibt dadurch immer dieselbe – ein gedrehter Koffer wird nur um
|
||||||
|
90° gedreht gezeichnet und bekommt keine andere Textur.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
units_w: int
|
units_w: int
|
||||||
@@ -25,11 +28,16 @@ class Case:
|
|||||||
x: float
|
x: float
|
||||||
y: float
|
y: float
|
||||||
color: tuple[int, int, int] = config.CASE_COLORS[0]
|
color: tuple[int, int, int] = config.CASE_COLORS[0]
|
||||||
|
texture: tuple[int, int] | None = None
|
||||||
placed: bool = False
|
placed: bool = False
|
||||||
snapped: bool = False
|
snapped: bool = False
|
||||||
allow_snap: bool = False
|
allow_snap: bool = False
|
||||||
can_rotate: bool = True
|
can_rotate: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.texture is None:
|
||||||
|
self.texture = (self.units_w, self.units_h)
|
||||||
|
|
||||||
# -- Geometrie ------------------------------------------------------
|
# -- Geometrie ------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
def width(self) -> float:
|
def width(self) -> float:
|
||||||
@@ -47,16 +55,26 @@ class Case:
|
|||||||
def bottom(self) -> float:
|
def bottom(self) -> float:
|
||||||
return self.y + self.height
|
return self.y + self.height
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_rotated(self) -> bool:
|
||||||
|
"""Steht der Koffer quer zu seiner Textur?"""
|
||||||
|
return (self.units_w, self.units_h) != self.texture
|
||||||
|
|
||||||
# -- Bewegung -------------------------------------------------------
|
# -- Bewegung -------------------------------------------------------
|
||||||
def move(self, dt: float, wall_x: float) -> None:
|
def move(
|
||||||
|
self,
|
||||||
|
dt: float,
|
||||||
|
wall_x: float,
|
||||||
|
difficulty: Difficulty = DEFAULT_DIFFICULTY,
|
||||||
|
) -> None:
|
||||||
"""Bewegt den Koffer nach links, mit Abbremsen kurz vor der Barriere."""
|
"""Bewegt den Koffer nach links, mit Abbremsen kurz vor der Barriere."""
|
||||||
if self.placed:
|
if self.placed:
|
||||||
return
|
return
|
||||||
distance = self.x - wall_x
|
distance = self.x - wall_x
|
||||||
speed = (
|
speed = (
|
||||||
config.CASE_SPEED_FAST
|
difficulty.speed_fast
|
||||||
if distance > config.FAST_TO_SLOW_DISTANCE
|
if distance > config.FAST_TO_SLOW_DISTANCE
|
||||||
else config.CASE_SPEED_SLOW
|
else difficulty.speed_slow
|
||||||
)
|
)
|
||||||
self.x -= speed * dt
|
self.x -= speed * dt
|
||||||
if self.x <= wall_x + config.SNAP_THRESHOLD:
|
if self.x <= wall_x + config.SNAP_THRESHOLD:
|
||||||
|
|||||||
+21
-20
@@ -4,18 +4,18 @@ Alle Geometrieangaben beziehen sich auf eine virtuelle Auflösung von
|
|||||||
1920x1080. Das Fenster wird per ``pygame.SCALED`` automatisch auf die
|
1920x1080. Das Fenster wird per ``pygame.SCALED`` automatisch auf die
|
||||||
tatsächliche Fenstergrösse skaliert – dadurch sieht das Spiel auf einem
|
tatsächliche Fenstergrösse skaliert – dadurch sieht das Spiel auf einem
|
||||||
MacBook (Retina, 1440x900, …) genauso aus wie auf einem 1080p-Monitor.
|
MacBook (Retina, 1440x900, …) genauso aus wie auf einem 1080p-Monitor.
|
||||||
|
|
||||||
|
Die Spielgeschwindigkeit steht in :mod:`loader_game.difficulty`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Pfade
|
# Pfade
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
RUNTIME_DIR = BASE_DIR / "runtime"
|
|
||||||
|
|
||||||
IMAGES_DIR = BASE_DIR / "images"
|
IMAGES_DIR = BASE_DIR / "images"
|
||||||
FONTS_DIR = BASE_DIR / "fonts"
|
FONTS_DIR = BASE_DIR / "fonts"
|
||||||
@@ -66,10 +66,8 @@ MAX_UNITS_W = 4
|
|||||||
MAX_UNITS_H = 3
|
MAX_UNITS_H = 3
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Spieltempo (jetzt in Pixel pro Sekunde statt pro Frame)
|
# Spieltempo (Pixel pro Sekunde; Stufen siehe difficulty.py)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
CASE_SPEED_FAST = 720 # früher: 6 px/Frame bei 120 fps
|
|
||||||
CASE_SPEED_SLOW = 240 # früher: 2 px/Frame bei 120 fps
|
|
||||||
FAST_TO_SLOW_DISTANCE = 120 # ab hier wird abgebremst
|
FAST_TO_SLOW_DISTANCE = 120 # ab hier wird abgebremst
|
||||||
SNAP_THRESHOLD = 60 # ab hier darf eingerastet werden
|
SNAP_THRESHOLD = 60 # ab hier darf eingerastet werden
|
||||||
PLATFORM_TOLERANCE = 5 # Toleranz für "bündig an der Barriere"
|
PLATFORM_TOLERANCE = 5 # Toleranz für "bündig an der Barriere"
|
||||||
@@ -105,7 +103,10 @@ COLOR_WALL = (255, 0, 0)
|
|||||||
COLOR_WHITE = (255, 255, 255)
|
COLOR_WHITE = (255, 255, 255)
|
||||||
COLOR_HINT_SNAP = (0, 255, 0)
|
COLOR_HINT_SNAP = (0, 255, 0)
|
||||||
COLOR_HINT_STACK = (255, 255, 0)
|
COLOR_HINT_STACK = (255, 255, 0)
|
||||||
COLOR_ERROR = (255, 100, 100)
|
COLOR_DIM = (25, 25, 25)
|
||||||
|
COLOR_MENU_BOX = (60, 60, 60)
|
||||||
|
COLOR_MENU_BOX_ACTIVE = (200, 165, 0)
|
||||||
|
COLOR_MENU_BORDER = (120, 120, 120)
|
||||||
COLOR_PROGRESS_TRACK = (50, 50, 50)
|
COLOR_PROGRESS_TRACK = (50, 50, 50)
|
||||||
CASE_COLORS = ((200, 0, 0), (0, 200, 0), (0, 0, 200))
|
CASE_COLORS = ((200, 0, 0), (0, 200, 0), (0, 0, 200))
|
||||||
|
|
||||||
@@ -125,27 +126,27 @@ PROGRESS_BAR_RADIUS = 10
|
|||||||
FONT_SIZE = 48
|
FONT_SIZE = 48
|
||||||
FONT_SIZE_SMALL = 34
|
FONT_SIZE_SMALL = 34
|
||||||
TITLE_TEXT = WINDOW_TITLE
|
TITLE_TEXT = WINDOW_TITLE
|
||||||
|
SUBTITLE_TEMPLATE = "Stufe {difficulty}"
|
||||||
NEXT_LABEL = "Next:"
|
NEXT_LABEL = "Next:"
|
||||||
NEXT_PREVIEW_COUNT = 3
|
NEXT_PREVIEW_COUNT = 3
|
||||||
NEXT_PREVIEW_SCALE = 0.6 # Vorschau-Koffer sind kleiner als im Auflieger
|
NEXT_PREVIEW_SCALE = 0.6 # Vorschau-Koffer sind kleiner als im Auflieger
|
||||||
NEXT_PREVIEW_MARGIN = 20
|
NEXT_PREVIEW_MARGIN = 20
|
||||||
HINT_SNAP = "Snap bereit!"
|
HINT_SNAP = "Snap bereit!"
|
||||||
HINT_STACK = "OnTop möglich!"
|
HINT_STACK = "OnTop möglich!"
|
||||||
GAME_OVER_GOOD = "Gratuliere {name}! Du hast {percent}% geladen!"
|
|
||||||
GAME_OVER_NORMAL = "{name}, du hast {percent}% geladen."
|
|
||||||
GAME_OVER_HINT = "Drücke A oder SPACE um zu beenden."
|
|
||||||
GAME_OVER_GOOD_PERCENT = 75
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Spieler / Punkte-Server
|
# Menü (Stufenauswahl)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
DEFAULT_PLAYER_NAME = "Spieler"
|
MENU_TITLE = "Stufe wählen"
|
||||||
MAX_PLAYER_NAME_LENGTH = 14
|
MENU_HINT = "← → Stufe wählen A / SPACE starten"
|
||||||
|
MENU_BOX_WIDTH = 320
|
||||||
|
MENU_BOX_HEIGHT = 140
|
||||||
|
MENU_BOX_GAP = 60
|
||||||
|
MENU_BOX_BORDER = 5
|
||||||
|
MENU_TOP = 470
|
||||||
|
|
||||||
# Der Punkte-Upload ist standardmässig deaktiviert. Zum Aktivieren:
|
# --------------------------------------------------------------------------
|
||||||
# export HAG_SCORE_URL="https://…/api/submit_points.php"
|
# Spielende
|
||||||
# export HAG_SCORE_API_KEY="…"
|
# --------------------------------------------------------------------------
|
||||||
# (Schlüssel gehören in die Umgebung, nicht in den Quellcode.)
|
RESULT_TEMPLATE = "Stufe {difficulty}: {percent}% Beladung"
|
||||||
SCORE_URL = os.environ.get("HAG_SCORE_URL", "")
|
RESULT_HINT = "Nochmals? A / SPACE = neue Runde ESC = beenden"
|
||||||
SCORE_API_KEY = os.environ.get("HAG_SCORE_API_KEY", "")
|
|
||||||
SCORE_TIMEOUT = 5.0
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Schwierigkeitsstufen: wie schnell die Koffer einlaufen.
|
||||||
|
|
||||||
|
``hard`` entspricht dem Tempo des Altstands (6 bzw. 2 Pixel pro Frame bei
|
||||||
|
120 fps = 720 bzw. 240 Pixel pro Sekunde).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Difficulty:
|
||||||
|
"""Eine Stufe. ``speed_fast`` gilt bis kurz vor der Barriere, dann ``speed_slow``."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
speed_fast: float
|
||||||
|
speed_slow: float
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.label
|
||||||
|
|
||||||
|
|
||||||
|
DIFFICULTIES: tuple[Difficulty, ...] = (
|
||||||
|
Difficulty("easy", "Easy", speed_fast=420.0, speed_slow=140.0),
|
||||||
|
Difficulty("normal", "Normal", speed_fast=570.0, speed_slow=190.0),
|
||||||
|
Difficulty("hard", "Hard", speed_fast=720.0, speed_slow=240.0),
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_DIFFICULTY: Difficulty = DIFFICULTIES[1]
|
||||||
|
|
||||||
|
|
||||||
|
def by_key(key: str) -> Difficulty:
|
||||||
|
for difficulty in DIFFICULTIES:
|
||||||
|
if difficulty.key == key:
|
||||||
|
return difficulty
|
||||||
|
return DEFAULT_DIFFICULTY
|
||||||
+39
-40
@@ -8,15 +8,23 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
from . import config, scene
|
from . import config, scene
|
||||||
from .case import Case
|
from .case import Case
|
||||||
|
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
|
||||||
from .input_map import Action
|
from .input_map import Action
|
||||||
from .net import submit_score
|
|
||||||
from .sequences import CaseSpec, FALLBACK_SEQUENCE
|
from .sequences import CaseSpec, FALLBACK_SEQUENCE
|
||||||
from .trailer import Trailer
|
from .trailer import Trailer
|
||||||
|
|
||||||
|
|
||||||
class State(enum.Enum):
|
class State(enum.Enum):
|
||||||
PLAYING = "playing"
|
PLAYING = "playing"
|
||||||
GAME_OVER = "game_over"
|
FINISHED = "finished"
|
||||||
|
|
||||||
|
|
||||||
|
class HandleResult(enum.Enum):
|
||||||
|
"""Was der Aufrufer nach einer Aktion tun soll."""
|
||||||
|
|
||||||
|
CONTINUE = "continue"
|
||||||
|
NEW_ROUND = "new_round" # zurück zur Stufenauswahl
|
||||||
|
QUIT = "quit" # Programm beenden (Esc / B)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -45,14 +53,12 @@ class Game:
|
|||||||
assets,
|
assets,
|
||||||
sequences,
|
sequences,
|
||||||
*,
|
*,
|
||||||
name: str = config.DEFAULT_PLAYER_NAME,
|
difficulty: Difficulty = DEFAULT_DIFFICULTY,
|
||||||
rng: random.Random | None = None,
|
rng: random.Random | None = None,
|
||||||
submit_scores: bool = True,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.assets = assets
|
self.assets = assets
|
||||||
self.rng = rng or random.Random()
|
self.rng = rng or random.Random()
|
||||||
self.name = name
|
self.difficulty = difficulty
|
||||||
self.submit_scores = submit_scores
|
|
||||||
|
|
||||||
self.state = State.PLAYING
|
self.state = State.PLAYING
|
||||||
self.trailer = Trailer()
|
self.trailer = Trailer()
|
||||||
@@ -64,8 +70,6 @@ class Game:
|
|||||||
self.animation: Animation | None = None
|
self.animation: Animation | None = None
|
||||||
self.shake = 0.0
|
self.shake = 0.0
|
||||||
self.percent = 0.0
|
self.percent = 0.0
|
||||||
self.status_line = ""
|
|
||||||
self.status_is_error = False
|
|
||||||
|
|
||||||
self._spawn()
|
self._spawn()
|
||||||
|
|
||||||
@@ -98,20 +102,21 @@ class Game:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Eingabe
|
# Eingabe
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def handle(self, action: Action) -> bool:
|
def handle(self, action: Action) -> HandleResult:
|
||||||
"""Verarbeitet eine Aktion. Rückgabe ``False`` = Spiel beenden."""
|
|
||||||
if action is Action.BACK:
|
if action is Action.BACK:
|
||||||
return False
|
return HandleResult.QUIT
|
||||||
|
|
||||||
if self.state is State.GAME_OVER:
|
if self.state is State.FINISHED:
|
||||||
return action is not Action.PRIMARY
|
if action is Action.PRIMARY:
|
||||||
|
return HandleResult.NEW_ROUND
|
||||||
|
return HandleResult.CONTINUE
|
||||||
|
|
||||||
if self.animation is not None or self.current is None:
|
if self.animation is not None or self.current is None:
|
||||||
return True
|
return HandleResult.CONTINUE
|
||||||
|
|
||||||
case = self.current
|
case = self.current
|
||||||
if case.placed or not case.allow_snap:
|
if case.placed or not case.allow_snap:
|
||||||
return True
|
return HandleResult.CONTINUE
|
||||||
|
|
||||||
if action is Action.PRIMARY:
|
if action is Action.PRIMARY:
|
||||||
self._start_snap(case)
|
self._start_snap(case)
|
||||||
@@ -119,7 +124,7 @@ class Game:
|
|||||||
self._start_rotate(case)
|
self._start_rotate(case)
|
||||||
elif action is Action.UP:
|
elif action is Action.UP:
|
||||||
self._start_stack(case)
|
self._start_stack(case)
|
||||||
return True
|
return HandleResult.CONTINUE
|
||||||
|
|
||||||
def _start_snap(self, case: Case) -> None:
|
def _start_snap(self, case: Case) -> None:
|
||||||
self._stop_roll()
|
self._stop_roll()
|
||||||
@@ -179,7 +184,7 @@ class Game:
|
|||||||
self._finish()
|
self._finish()
|
||||||
return
|
return
|
||||||
|
|
||||||
case.move(dt, self.trailer.wall_x)
|
case.move(dt, self.trailer.wall_x, self.difficulty)
|
||||||
if not case.placed and case.x <= self.trailer.wall_x:
|
if not case.placed and case.x <= self.trailer.wall_x:
|
||||||
self._stack_with_gap(case, sound="fail")
|
self._stack_with_gap(case, sound="fail")
|
||||||
return
|
return
|
||||||
@@ -258,25 +263,25 @@ class Game:
|
|||||||
self._finish()
|
self._finish()
|
||||||
|
|
||||||
def _finish(self) -> None:
|
def _finish(self) -> None:
|
||||||
if self.state is State.GAME_OVER:
|
if self.state is State.FINISHED:
|
||||||
return
|
return
|
||||||
self.state = State.GAME_OVER
|
self.state = State.FINISHED
|
||||||
self._stop_roll()
|
self._stop_roll()
|
||||||
self.percent = self.trailer.load_percent()
|
self.percent = self.trailer.load_percent()
|
||||||
if self.percent >= 99.9:
|
if self.percent >= 99.9:
|
||||||
self.percent = 100.0
|
self.percent = 100.0
|
||||||
if self.submit_scores:
|
|
||||||
ok, message = submit_score(self.name, self.percent)
|
|
||||||
if ok or config.SCORE_URL:
|
|
||||||
self.status_line = message
|
|
||||||
self.status_is_error = not ok
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Zeichnen
|
# Zeichnen
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def draw(self, surface) -> None:
|
def draw(self, surface) -> None:
|
||||||
assets = self.assets
|
assets = self.assets
|
||||||
scene.draw_top_bar(surface, assets, self.trailer.load_percent())
|
scene.draw_top_bar(
|
||||||
|
surface,
|
||||||
|
assets,
|
||||||
|
self.trailer.load_percent(),
|
||||||
|
subtitle=config.SUBTITLE_TEMPLATE.format(difficulty=self.difficulty.label),
|
||||||
|
)
|
||||||
scene.draw_background(surface, assets)
|
scene.draw_background(surface, assets)
|
||||||
|
|
||||||
offset = (0, 0)
|
offset = (0, 0)
|
||||||
@@ -304,22 +309,16 @@ class Game:
|
|||||||
surface, assets, self.trailer.find_platform(case) is not None
|
surface, assets, self.trailer.find_platform(case) is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.state is State.GAME_OVER:
|
if self.state is State.FINISHED:
|
||||||
scene.draw_overlay_lines(
|
scene.draw_overlay_lines(surface, assets, self.result_lines())
|
||||||
surface,
|
|
||||||
assets,
|
|
||||||
self.game_over_lines(),
|
|
||||||
status=self.status_line,
|
|
||||||
status_color=config.COLOR_ERROR if self.status_is_error else config.COLOR_HINT_SNAP,
|
|
||||||
)
|
|
||||||
|
|
||||||
def game_over_lines(self) -> list[str]:
|
def result_lines(self) -> list[str]:
|
||||||
percent = int(self.percent)
|
return [
|
||||||
if self.percent >= config.GAME_OVER_GOOD_PERCENT:
|
config.RESULT_TEMPLATE.format(
|
||||||
first = config.GAME_OVER_GOOD.format(name=self.name, percent=percent)
|
difficulty=self.difficulty.label, percent=int(self.percent)
|
||||||
else:
|
),
|
||||||
first = config.GAME_OVER_NORMAL.format(name=self.name, percent=percent)
|
config.RESULT_HINT,
|
||||||
return [first, config.GAME_OVER_HINT]
|
]
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Hilfen
|
# Hilfen
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ class Intro:
|
|||||||
Reissverschluss, der den Auflieger "öffnet".
|
Reissverschluss, der den Auflieger "öffnet".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, assets) -> None:
|
def __init__(self, assets, *, show_instruction: bool = True) -> None:
|
||||||
self.assets = assets
|
self.assets = assets
|
||||||
self.phase = "instruction" if assets.instruction is not None else "countdown"
|
show_tutorial = show_instruction and assets.instruction is not None
|
||||||
|
self.phase = "instruction" if show_tutorial else "countdown"
|
||||||
self.timer = 0.0
|
self.timer = 0.0
|
||||||
self.dot_index = 0
|
self.dot_index = 0
|
||||||
if self.phase == "countdown":
|
if self.phase == "countdown":
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Startmenü: Schwierigkeitsstufe wählen (Easy / Normal / Hard)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pygame
|
||||||
|
|
||||||
|
from . import config, scene
|
||||||
|
from .difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, Difficulty
|
||||||
|
from .input_map import Action
|
||||||
|
|
||||||
|
|
||||||
|
class DifficultyMenu:
|
||||||
|
"""Drei Kacheln, Auswahl mit ← / →, Start mit A / Space."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
assets,
|
||||||
|
options: tuple[Difficulty, ...] = DIFFICULTIES,
|
||||||
|
selected: Difficulty = DEFAULT_DIFFICULTY,
|
||||||
|
) -> None:
|
||||||
|
self.assets = assets
|
||||||
|
self.options = list(options)
|
||||||
|
self.index = self.options.index(selected) if selected in self.options else 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@property
|
||||||
|
def selected(self) -> Difficulty:
|
||||||
|
return self.options[self.index]
|
||||||
|
|
||||||
|
def handle(self, action: Action) -> Difficulty | None:
|
||||||
|
"""Rückgabe: gewählte Stufe, sobald bestätigt wurde – sonst ``None``."""
|
||||||
|
if action is Action.LEFT:
|
||||||
|
self.index = (self.index - 1) % len(self.options)
|
||||||
|
elif action is Action.RIGHT:
|
||||||
|
self.index = (self.index + 1) % len(self.options)
|
||||||
|
elif action is Action.PRIMARY:
|
||||||
|
return self.selected
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def draw(self, surface: pygame.Surface) -> None:
|
||||||
|
scene.draw_top_bar(surface, self.assets, 0.0)
|
||||||
|
scene.draw_background(surface, self.assets)
|
||||||
|
scene.clear_bottom_bar(surface)
|
||||||
|
scene.draw_dim(surface, 170)
|
||||||
|
|
||||||
|
font = self.assets.font
|
||||||
|
small = self.assets.font_small or font
|
||||||
|
|
||||||
|
title = font.render(config.MENU_TITLE, True, config.COLOR_WHITE)
|
||||||
|
surface.blit(
|
||||||
|
title,
|
||||||
|
(config.SCREEN_WIDTH // 2 - title.get_width() // 2, config.MENU_TOP - 110),
|
||||||
|
)
|
||||||
|
|
||||||
|
total_width = len(self.options) * config.MENU_BOX_WIDTH + (
|
||||||
|
len(self.options) - 1
|
||||||
|
) * config.MENU_BOX_GAP
|
||||||
|
x = (config.SCREEN_WIDTH - total_width) // 2
|
||||||
|
|
||||||
|
for index, difficulty in enumerate(self.options):
|
||||||
|
rect = pygame.Rect(
|
||||||
|
x, config.MENU_TOP, config.MENU_BOX_WIDTH, config.MENU_BOX_HEIGHT
|
||||||
|
)
|
||||||
|
active = index == self.index
|
||||||
|
pygame.draw.rect(
|
||||||
|
surface,
|
||||||
|
config.COLOR_MENU_BOX_ACTIVE if active else config.COLOR_MENU_BOX,
|
||||||
|
rect,
|
||||||
|
border_radius=14,
|
||||||
|
)
|
||||||
|
pygame.draw.rect(
|
||||||
|
surface,
|
||||||
|
config.COLOR_WHITE if active else config.COLOR_MENU_BORDER,
|
||||||
|
rect,
|
||||||
|
width=config.MENU_BOX_BORDER,
|
||||||
|
border_radius=14,
|
||||||
|
)
|
||||||
|
label = font.render(
|
||||||
|
difficulty.label, True, (0, 0, 0) if active else config.COLOR_WHITE
|
||||||
|
)
|
||||||
|
surface.blit(
|
||||||
|
label,
|
||||||
|
(
|
||||||
|
rect.centerx - label.get_width() // 2,
|
||||||
|
rect.centery - label.get_height() // 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
x += config.MENU_BOX_WIDTH + config.MENU_BOX_GAP
|
||||||
|
|
||||||
|
hint = small.render(config.MENU_HINT, True, config.COLOR_WHITE)
|
||||||
|
hint_y = config.MENU_TOP + config.MENU_BOX_HEIGHT + 60
|
||||||
|
band = pygame.Rect(
|
||||||
|
config.SCREEN_WIDTH // 2 - hint.get_width() // 2 - 30,
|
||||||
|
hint_y - 10,
|
||||||
|
hint.get_width() + 60,
|
||||||
|
hint.get_height() + 20,
|
||||||
|
)
|
||||||
|
pygame.draw.rect(surface, config.COLOR_DIM, band, border_radius=10)
|
||||||
|
surface.blit(hint, (band.x + 30, hint_y))
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
"""Optionaler Punkte-Upload.
|
|
||||||
|
|
||||||
Standardmässig **deaktiviert**: Es passiert nichts, solange ``HAG_SCORE_URL``
|
|
||||||
nicht gesetzt ist. Zugangsdaten kommen aus der Umgebung
|
|
||||||
(``HAG_SCORE_API_KEY``) – nicht aus dem Quellcode.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import urllib.error
|
|
||||||
import urllib.parse
|
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from . import config
|
|
||||||
|
|
||||||
|
|
||||||
def submit_score(
|
|
||||||
name: str,
|
|
||||||
percent: float,
|
|
||||||
*,
|
|
||||||
url: str | None = None,
|
|
||||||
api_key: str | None = None,
|
|
||||||
timeout: float | None = None,
|
|
||||||
) -> tuple[bool, str]:
|
|
||||||
"""Sendet das Ergebnis. Rückgabe: ``(erfolgreich, meldung)``."""
|
|
||||||
endpoint = url if url is not None else config.SCORE_URL
|
|
||||||
if not endpoint:
|
|
||||||
return False, ""
|
|
||||||
|
|
||||||
payload = urllib.parse.urlencode(
|
|
||||||
{
|
|
||||||
"name": name,
|
|
||||||
"score": int(percent),
|
|
||||||
"api_key": api_key if api_key is not None else config.SCORE_API_KEY,
|
|
||||||
}
|
|
||||||
).encode("utf-8")
|
|
||||||
|
|
||||||
try:
|
|
||||||
request = urllib.request.Request(endpoint, data=payload)
|
|
||||||
with urllib.request.urlopen(request, timeout=timeout or config.SCORE_TIMEOUT) as response:
|
|
||||||
body = response.read().decode("utf-8", "replace")
|
|
||||||
except (urllib.error.URLError, OSError, ValueError) as exc:
|
|
||||||
return False, f"Punkte konnten nicht gesendet werden: {exc}"
|
|
||||||
|
|
||||||
if "success" not in body.lower():
|
|
||||||
return False, "Punkte konnten nicht gesendet werden (unerwartete Serverantwort)."
|
|
||||||
return True, "Punkte gesendet."
|
|
||||||
+41
-19
@@ -12,13 +12,22 @@ from .sequences import CaseSpec
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Hintergrund-Bänder
|
# Hintergrund-Bänder
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
def draw_top_bar(surface: pygame.Surface, assets, percent: float) -> None:
|
def draw_top_bar(surface: pygame.Surface, assets, percent: float, subtitle: str = "") -> None:
|
||||||
surface.fill(
|
surface.fill(
|
||||||
config.COLOR_TOP_BAR, (0, 0, config.SCREEN_WIDTH, config.TOP_BAR_HEIGHT)
|
config.COLOR_TOP_BAR, (0, 0, config.SCREEN_WIDTH, config.TOP_BAR_HEIGHT)
|
||||||
)
|
)
|
||||||
font = assets.font
|
font = assets.font
|
||||||
title = font.render(config.TITLE_TEXT, True, config.COLOR_WHITE)
|
title = font.render(config.TITLE_TEXT, True, config.COLOR_WHITE)
|
||||||
surface.blit(title, (40, config.TOP_BAR_HEIGHT // 2 - title.get_height() // 2))
|
surface.blit(title, (40, config.TOP_BAR_HEIGHT // 2 - title.get_height() // 2))
|
||||||
|
if subtitle:
|
||||||
|
small = (assets.font_small or font).render(subtitle, True, config.COLOR_WHITE)
|
||||||
|
surface.blit(
|
||||||
|
small,
|
||||||
|
(
|
||||||
|
40 + title.get_width() + 48,
|
||||||
|
config.TOP_BAR_HEIGHT // 2 - small.get_height() // 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
_draw_progress_bar(surface, assets, percent)
|
_draw_progress_bar(surface, assets, percent)
|
||||||
|
|
||||||
|
|
||||||
@@ -56,11 +65,21 @@ def draw_background(surface: pygame.Surface, assets) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def draw_bottom_bar(surface: pygame.Surface, assets, upcoming: list[CaseSpec]) -> None:
|
def clear_bottom_bar(surface: pygame.Surface) -> None:
|
||||||
|
"""Untere Leiste schwärzen (ohne Inhalt)."""
|
||||||
surface.fill(
|
surface.fill(
|
||||||
config.COLOR_BOTTOM_BAR,
|
config.COLOR_BOTTOM_BAR,
|
||||||
(0, config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT, config.SCREEN_WIDTH, config.BOTTOM_BAR_HEIGHT),
|
(
|
||||||
|
0,
|
||||||
|
config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT,
|
||||||
|
config.SCREEN_WIDTH,
|
||||||
|
config.BOTTOM_BAR_HEIGHT,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_bottom_bar(surface: pygame.Surface, assets, upcoming: list[CaseSpec]) -> None:
|
||||||
|
clear_bottom_bar(surface)
|
||||||
top = config.SCREEN_HEIGHT - config.BOTTOM_BAR_HEIGHT
|
top = config.SCREEN_HEIGHT - config.BOTTOM_BAR_HEIGHT
|
||||||
label = (assets.font_small or assets.font).render(config.NEXT_LABEL, True, config.COLOR_WHITE)
|
label = (assets.font_small or assets.font).render(config.NEXT_LABEL, True, config.COLOR_WHITE)
|
||||||
surface.blit(label, (40, top + 12))
|
surface.blit(label, (40, top + 12))
|
||||||
@@ -140,12 +159,19 @@ def _spec_size(spec: CaseSpec) -> tuple[int, int]:
|
|||||||
|
|
||||||
|
|
||||||
def draw_case(surface: pygame.Surface, assets, case: Case, angle: float = 0.0) -> None:
|
def draw_case(surface: pygame.Surface, assets, case: Case, angle: float = 0.0) -> None:
|
||||||
|
"""Zeichnet einen Koffer – immer mit **seiner eigenen** Textur.
|
||||||
|
|
||||||
|
Ein gedrehter Koffer wird um 90° gedreht gezeichnet, bekommt aber keine
|
||||||
|
andere Grafik.
|
||||||
|
"""
|
||||||
x, y = int(case.x), int(case.y)
|
x, y = int(case.x), int(case.y)
|
||||||
width, height = int(case.width), int(case.height)
|
width, height = int(case.width), int(case.height)
|
||||||
image = assets.case_surface(case.units_w, case.units_h) if assets is not None else None
|
image = assets.case_surface(*case.texture) if assets is not None else None
|
||||||
if image is None:
|
if image is None:
|
||||||
pygame.draw.rect(surface, case.color, (x, y, width, height))
|
pygame.draw.rect(surface, case.color, (x, y, width, height))
|
||||||
return
|
return
|
||||||
|
if case.is_rotated:
|
||||||
|
image = pygame.transform.rotate(image, -90)
|
||||||
if angle:
|
if angle:
|
||||||
rotated = pygame.transform.rotate(image, angle)
|
rotated = pygame.transform.rotate(image, angle)
|
||||||
surface.blit(rotated, rotated.get_rect(bottomleft=(x, y + height)))
|
surface.blit(rotated, rotated.get_rect(bottomleft=(x, y + height)))
|
||||||
@@ -171,23 +197,26 @@ def draw_hint(surface: pygame.Surface, assets, stack_possible: bool) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def draw_dim(surface: pygame.Surface, alpha: int = 170) -> None:
|
||||||
|
"""Dunkelt das ganze Bild ab (für Menü und Ergebnis)."""
|
||||||
|
overlay = pygame.Surface((config.SCREEN_WIDTH, config.SCREEN_HEIGHT))
|
||||||
|
overlay.set_alpha(alpha)
|
||||||
|
overlay.fill((0, 0, 0))
|
||||||
|
surface.blit(overlay, (0, 0))
|
||||||
|
|
||||||
|
|
||||||
def draw_overlay_lines(
|
def draw_overlay_lines(
|
||||||
surface: pygame.Surface,
|
surface: pygame.Surface,
|
||||||
assets,
|
assets,
|
||||||
lines: list[str],
|
lines: list[str],
|
||||||
*,
|
|
||||||
status: str = "",
|
|
||||||
status_color: tuple[int, int, int] = config.COLOR_ERROR,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
overlay = pygame.Surface((config.SCREEN_WIDTH, config.SCREEN_HEIGHT))
|
"""Dunkler Schleier mit zentrierten Textzeilen (Ergebnis-Anzeige)."""
|
||||||
overlay.set_alpha(220)
|
draw_dim(surface, 220)
|
||||||
overlay.fill((0, 0, 0))
|
|
||||||
surface.blit(overlay, (0, 0))
|
|
||||||
|
|
||||||
center_y = config.SCREEN_HEIGHT // 2
|
center_y = config.SCREEN_HEIGHT // 2
|
||||||
line_gap = 16
|
line_gap = 16
|
||||||
total_height = 0
|
|
||||||
rendered: list[pygame.Surface] = []
|
rendered: list[pygame.Surface] = []
|
||||||
|
total_height = 0
|
||||||
for line in lines:
|
for line in lines:
|
||||||
image = assets.font.render(line, True, config.COLOR_WHITE)
|
image = assets.font.render(line, True, config.COLOR_WHITE)
|
||||||
rendered.append(image)
|
rendered.append(image)
|
||||||
@@ -196,10 +225,3 @@ def draw_overlay_lines(
|
|||||||
for image in rendered:
|
for image in rendered:
|
||||||
surface.blit(image, (config.SCREEN_WIDTH // 2 - image.get_width() // 2, y))
|
surface.blit(image, (config.SCREEN_WIDTH // 2 - image.get_width() // 2, y))
|
||||||
y += image.get_height() + line_gap
|
y += image.get_height() + line_gap
|
||||||
|
|
||||||
if status:
|
|
||||||
small = assets.font_small.render(status, True, status_color)
|
|
||||||
surface.blit(
|
|
||||||
small,
|
|
||||||
(config.SCREEN_WIDTH // 2 - small.get_width() // 2, y + 20),
|
|
||||||
)
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Laufzeitdaten (letzter Spielername).
|
|
||||||
|
|
||||||
Alles, was zur Laufzeit geschrieben wird, landet in ``runtime/`` – dieser
|
|
||||||
Ordner ist in ``.gitignore`` eingetragen. Über die Umgebungsvariable
|
|
||||||
``HAG_RUNTIME_DIR`` lässt sich der Ort umbiegen (praktisch für Tests).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from . import config
|
|
||||||
|
|
||||||
LAST_PLAYER_FILE = "last_player.json"
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_dir() -> Path:
|
|
||||||
"""Verzeichnis für Laufzeitdaten; wird bei Bedarf angelegt."""
|
|
||||||
override = os.environ.get("HAG_RUNTIME_DIR")
|
|
||||||
path = Path(override) if override else config.RUNTIME_DIR
|
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def load_last_player() -> str | None:
|
|
||||||
try:
|
|
||||||
data = json.loads((runtime_dir() / LAST_PLAYER_FILE).read_text(encoding="utf-8"))
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return None
|
|
||||||
name = str(data.get("name", "")).strip()
|
|
||||||
return name or None
|
|
||||||
|
|
||||||
|
|
||||||
def save_last_player(name: str) -> None:
|
|
||||||
name = name.strip()[: config.MAX_PLAYER_NAME_LENGTH]
|
|
||||||
if not name:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
(runtime_dir() / LAST_PLAYER_FILE).write_text(
|
|
||||||
json.dumps({"name": name}, ensure_ascii=False, indent=2) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
# Laufzeitdaten sind nice-to-have – ein Schreibfehler darf das Spiel
|
|
||||||
# nicht stoppen.
|
|
||||||
pass
|
|
||||||
@@ -1,30 +1,43 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Einstiegspunkt des Habegger Loader Games.
|
"""Einstiegspunkt des Habegger Loader Games.
|
||||||
|
|
||||||
|
Ablauf: Tutorialbild (nur beim Programmstart) → Stufenauswahl → Ampel-Start →
|
||||||
|
Runde → Ergebnis ("Nochmals?") → Stufenauswahl → …
|
||||||
|
|
||||||
|
Beendet wird ausschliesslich mit ``Esc`` (bzw. Controller-Taste B).
|
||||||
|
|
||||||
Beispiele::
|
Beispiele::
|
||||||
|
|
||||||
./run.sh # Fenster, letzter Spielername
|
./run.sh # Fenster
|
||||||
./run.sh --fullscreen # Vollbild (Messe-/Eventbetrieb)
|
./run.sh --fullscreen # Vollbild (Messe-/Eventbetrieb)
|
||||||
./run.sh --name "Anna" # Name direkt setzen
|
|
||||||
./run.sh --smoke 10 # Selbsttest ohne Eingabegeräte
|
./run.sh --smoke 10 # Selbsttest ohne Eingabegeräte
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import enum
|
||||||
import random
|
import random
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import pygame
|
import pygame
|
||||||
|
|
||||||
from loader_game import config, storage
|
from loader_game import config
|
||||||
from loader_game.assets import Assets
|
from loader_game.assets import Assets
|
||||||
from loader_game.game import Game, State
|
from loader_game.difficulty import DEFAULT_DIFFICULTY, Difficulty
|
||||||
|
from loader_game.game import Game, HandleResult, State
|
||||||
from loader_game.input_map import Action, actions_from_event, controller_hint_lines
|
from loader_game.input_map import Action, actions_from_event, controller_hint_lines
|
||||||
from loader_game.intro import Intro
|
from loader_game.intro import Intro
|
||||||
|
from loader_game.menu import DifficultyMenu
|
||||||
from loader_game.sequences import load_sequences
|
from loader_game.sequences import load_sequences
|
||||||
|
|
||||||
|
|
||||||
|
class Phase(enum.Enum):
|
||||||
|
INTRO = "intro" # Tutorial und/oder Ampel-Start
|
||||||
|
MENU = "menu" # Stufenauswahl
|
||||||
|
PLAY = "play" # laufende Runde
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="Habegger Loader Game")
|
parser = argparse.ArgumentParser(description="Habegger Loader Game")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -32,11 +45,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Vollbild starten (Standard: Fenster; F11 schaltet jederzeit um)",
|
help="Vollbild starten (Standard: Fenster; F11 schaltet jederzeit um)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--name",
|
|
||||||
default=None,
|
|
||||||
help="Spielername (sonst der zuletzt gespeicherte aus runtime/)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--seed",
|
"--seed",
|
||||||
type=int,
|
type=int,
|
||||||
@@ -48,8 +56,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|||||||
type=float,
|
type=float,
|
||||||
default=None,
|
default=None,
|
||||||
metavar="SEKUNDEN",
|
metavar="SEKUNDEN",
|
||||||
help="Selbsttest: bestätigt das Intro automatisch, spielt ohne Eingaben "
|
help="Selbsttest: spielt ohne Eingaben und beendet sich nach N Sekunden",
|
||||||
"und beendet sich nach N Sekunden",
|
|
||||||
)
|
)
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
@@ -98,15 +105,72 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
for warning in warnings:
|
for warning in warnings:
|
||||||
print("Sequenz-Warnung:", warning, file=sys.stderr)
|
print("Sequenz-Warnung:", warning, file=sys.stderr)
|
||||||
|
|
||||||
name = (args.name or storage.load_last_player() or config.DEFAULT_PLAYER_NAME).strip()
|
|
||||||
name = name[: config.MAX_PLAYER_NAME_LENGTH] or config.DEFAULT_PLAYER_NAME
|
|
||||||
storage.save_last_player(name)
|
|
||||||
|
|
||||||
rng = random.Random(args.seed)
|
rng = random.Random(args.seed)
|
||||||
intro: Intro | None = Intro(assets)
|
difficulty: Difficulty = DEFAULT_DIFFICULTY
|
||||||
game: Game | None = None
|
|
||||||
clock = pygame.time.Clock()
|
|
||||||
|
|
||||||
|
phase = Phase.INTRO
|
||||||
|
after_intro = Phase.MENU # nach dem Tutorial kommt die Stufenauswahl
|
||||||
|
intro: Intro | None = Intro(assets, show_instruction=True)
|
||||||
|
menu: DifficultyMenu | None = None
|
||||||
|
game: Game | None = None
|
||||||
|
|
||||||
|
def apply_action(action: Action) -> bool:
|
||||||
|
"""Verarbeitet eine Aktion in der aktuellen Phase.
|
||||||
|
|
||||||
|
Rückgabe ``False`` = Programm beenden.
|
||||||
|
"""
|
||||||
|
nonlocal phase, intro, menu, game, difficulty, after_intro
|
||||||
|
|
||||||
|
if action is Action.BACK:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if phase is Phase.INTRO and intro is not None:
|
||||||
|
intro.handle(action)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if phase is Phase.MENU and menu is not None:
|
||||||
|
selected = menu.handle(action)
|
||||||
|
if selected is not None:
|
||||||
|
difficulty = selected
|
||||||
|
intro = Intro(assets, show_instruction=False)
|
||||||
|
after_intro = Phase.PLAY
|
||||||
|
phase = Phase.INTRO
|
||||||
|
return True
|
||||||
|
|
||||||
|
if phase is Phase.PLAY and game is not None:
|
||||||
|
result = game.handle(action)
|
||||||
|
if result is HandleResult.QUIT:
|
||||||
|
return False
|
||||||
|
if result is HandleResult.NEW_ROUND:
|
||||||
|
menu = DifficultyMenu(assets, selected=difficulty)
|
||||||
|
phase = Phase.MENU
|
||||||
|
return True
|
||||||
|
|
||||||
|
def advance(dt: float) -> None:
|
||||||
|
"""Weiterrechnen und Phasenwechsel ohne Eingabe."""
|
||||||
|
nonlocal phase, intro, menu, game
|
||||||
|
|
||||||
|
if phase is Phase.INTRO and intro is not None:
|
||||||
|
if intro.update(dt):
|
||||||
|
intro = None
|
||||||
|
if after_intro is Phase.PLAY:
|
||||||
|
game = Game(assets, sequences, difficulty=difficulty, rng=rng)
|
||||||
|
else:
|
||||||
|
menu = DifficultyMenu(assets, selected=difficulty)
|
||||||
|
phase = after_intro
|
||||||
|
elif phase is Phase.PLAY and game is not None:
|
||||||
|
game.update(dt)
|
||||||
|
|
||||||
|
def draw() -> None:
|
||||||
|
if phase is Phase.INTRO and intro is not None:
|
||||||
|
intro.draw(screen)
|
||||||
|
elif phase is Phase.MENU and menu is not None:
|
||||||
|
menu.draw(screen)
|
||||||
|
elif phase is Phase.PLAY and game is not None:
|
||||||
|
game.draw(screen)
|
||||||
|
pygame.display.flip()
|
||||||
|
|
||||||
|
clock = pygame.time.Clock()
|
||||||
running = True
|
running = True
|
||||||
smoke_remaining = args.smoke
|
smoke_remaining = args.smoke
|
||||||
smoke_timer = 0.0
|
smoke_timer = 0.0
|
||||||
@@ -114,6 +178,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
while running:
|
while running:
|
||||||
dt = min(clock.tick(config.FPS) / 1000.0, 0.1)
|
dt = min(clock.tick(config.FPS) / 1000.0, 0.1)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- Eingabe
|
||||||
for event in pygame.event.get():
|
for event in pygame.event.get():
|
||||||
if event.type == pygame.QUIT:
|
if event.type == pygame.QUIT:
|
||||||
running = False
|
running = False
|
||||||
@@ -122,47 +187,39 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
pygame.display.toggle_fullscreen()
|
pygame.display.toggle_fullscreen()
|
||||||
continue
|
continue
|
||||||
for action in actions_from_event(event):
|
for action in actions_from_event(event):
|
||||||
if intro is not None:
|
if not apply_action(action):
|
||||||
intro.handle(action)
|
running = False
|
||||||
if action is Action.BACK:
|
break
|
||||||
running = False
|
|
||||||
elif game is not None:
|
|
||||||
running = game.handle(action)
|
|
||||||
if not running:
|
if not running:
|
||||||
break
|
break
|
||||||
|
|
||||||
if intro is not None:
|
advance(dt)
|
||||||
if intro.update(dt):
|
|
||||||
intro = None
|
|
||||||
game = Game(assets, sequences, name=name, rng=rng)
|
|
||||||
elif smoke_remaining is not None and intro.phase == "instruction":
|
|
||||||
intro.handle(Action.PRIMARY)
|
|
||||||
elif game is not None:
|
|
||||||
game.update(dt)
|
|
||||||
|
|
||||||
|
# Selbsttest: ohne Eingabegeräte mitspielen
|
||||||
if smoke_remaining is not None:
|
if smoke_remaining is not None:
|
||||||
smoke_remaining -= dt
|
smoke_remaining -= dt
|
||||||
if smoke_remaining <= 0:
|
if smoke_remaining <= 0:
|
||||||
running = False
|
running = False
|
||||||
elif game is not None and intro is None:
|
else:
|
||||||
smoke_timer -= dt
|
smoke_timer -= dt
|
||||||
if smoke_timer <= 0:
|
if smoke_timer <= 0:
|
||||||
smoke_timer = 0.25
|
smoke_timer = 0.3
|
||||||
action = _demo_action(game, rng)
|
if phase is Phase.PLAY and game is not None:
|
||||||
if action is not None:
|
demo = _demo_action(game, rng)
|
||||||
running = game.handle(action)
|
if demo is not None and not apply_action(demo):
|
||||||
|
running = False
|
||||||
|
elif not apply_action(Action.PRIMARY):
|
||||||
|
running = False
|
||||||
|
|
||||||
if intro is not None:
|
draw()
|
||||||
intro.draw(screen)
|
|
||||||
elif game is not None:
|
|
||||||
game.draw(screen)
|
|
||||||
pygame.display.flip()
|
|
||||||
|
|
||||||
if game is not None:
|
if game is not None:
|
||||||
percent = game.percent if game.state is State.GAME_OVER else game.trailer.load_percent()
|
percent = (
|
||||||
|
game.percent if game.state is State.FINISHED else game.trailer.load_percent()
|
||||||
|
)
|
||||||
print(
|
print(
|
||||||
f"Spiel beendet: Zustand={game.state.value}, "
|
f"Spiel beendet: Stufe={difficulty.label}, Beladung={percent:.0f}%, "
|
||||||
f"Beladung={percent:.0f}%, Koffer im Auflieger={len(game.trailer.stack)}"
|
f"Koffer im Auflieger={len(game.trailer.stack)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
pygame.quit()
|
pygame.quit()
|
||||||
|
|||||||
+180
-55
@@ -9,17 +9,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import tempfile
|
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
|
||||||
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
|
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
|
||||||
|
|
||||||
from loader_game import config, storage
|
from loader_game import config
|
||||||
from loader_game.case import Case
|
from loader_game.case import Case
|
||||||
from loader_game.game import Game, State
|
from loader_game.difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, by_key
|
||||||
|
from loader_game.game import Game, HandleResult, State
|
||||||
from loader_game.input_map import Action
|
from loader_game.input_map import Action
|
||||||
|
from loader_game.menu import DifficultyMenu
|
||||||
from loader_game.sequences import CaseSpec, load_sequences, parse_sequences
|
from loader_game.sequences import CaseSpec, load_sequences, parse_sequences
|
||||||
from loader_game.trailer import Trailer
|
from loader_game.trailer import Trailer
|
||||||
|
|
||||||
@@ -78,6 +78,25 @@ class LayoutTests(unittest.TestCase):
|
|||||||
self.assertEqual(config.UNIT_LENGTH, config.UNIT_HEIGHT)
|
self.assertEqual(config.UNIT_LENGTH, config.UNIT_HEIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
class DifficultyTests(unittest.TestCase):
|
||||||
|
def test_three_levels_in_order(self) -> None:
|
||||||
|
self.assertEqual([d.key for d in DIFFICULTIES], ["easy", "normal", "hard"])
|
||||||
|
self.assertEqual([d.label for d in DIFFICULTIES], ["Easy", "Normal", "Hard"])
|
||||||
|
|
||||||
|
def test_hard_matches_old_game_speed(self) -> None:
|
||||||
|
hard = by_key("hard")
|
||||||
|
self.assertEqual((hard.speed_fast, hard.speed_slow), (720.0, 240.0))
|
||||||
|
|
||||||
|
def test_speeds_increase_with_difficulty(self) -> None:
|
||||||
|
fast = [d.speed_fast for d in DIFFICULTIES]
|
||||||
|
slow = [d.speed_slow for d in DIFFICULTIES]
|
||||||
|
self.assertEqual(fast, sorted(fast))
|
||||||
|
self.assertEqual(slow, sorted(slow))
|
||||||
|
|
||||||
|
def test_unknown_key_falls_back_to_default(self) -> None:
|
||||||
|
self.assertIs(by_key("gibtsnicht"), DEFAULT_DIFFICULTY)
|
||||||
|
|
||||||
|
|
||||||
class CaseTests(unittest.TestCase):
|
class CaseTests(unittest.TestCase):
|
||||||
def make_case(self, units_w: int = 2, units_h: int = 1, x: float | None = None) -> Case:
|
def make_case(self, units_w: int = 2, units_h: int = 1, x: float | None = None) -> Case:
|
||||||
return Case(
|
return Case(
|
||||||
@@ -100,6 +119,13 @@ class CaseTests(unittest.TestCase):
|
|||||||
slow_step = before - case.x
|
slow_step = before - case.x
|
||||||
self.assertLess(slow_step, fast_step)
|
self.assertLess(slow_step, fast_step)
|
||||||
|
|
||||||
|
def test_difficulty_scales_speed(self) -> None:
|
||||||
|
easy = self.make_case()
|
||||||
|
hard = self.make_case()
|
||||||
|
easy.move(0.1, wall_x=0, difficulty=DIFFICULTIES[0])
|
||||||
|
hard.move(0.1, wall_x=0, difficulty=DIFFICULTIES[2])
|
||||||
|
self.assertGreater(easy.x, hard.x) # easy ist langsamer, bleibt weiter rechts
|
||||||
|
|
||||||
def test_allow_snap_only_near_wall(self) -> None:
|
def test_allow_snap_only_near_wall(self) -> None:
|
||||||
case = self.make_case()
|
case = self.make_case()
|
||||||
case.move(1 / 60, wall_x=0)
|
case.move(1 / 60, wall_x=0)
|
||||||
@@ -116,14 +142,19 @@ class CaseTests(unittest.TestCase):
|
|||||||
case.move(1 / 60, wall_x=0)
|
case.move(1 / 60, wall_x=0)
|
||||||
self.assertEqual(case.x, start)
|
self.assertEqual(case.x, start)
|
||||||
|
|
||||||
def test_rotate_swaps_footprint_and_respects_trailer_height(self) -> None:
|
def test_case_keeps_its_texture_when_rotated(self) -> None:
|
||||||
case = self.make_case(units_w=3, units_h=1)
|
case = self.make_case(units_w=1, units_h=2)
|
||||||
self.assertTrue(case.fits_trailer_height())
|
self.assertEqual(case.texture, (1, 2))
|
||||||
case.rotate_footprint()
|
self.assertFalse(case.is_rotated)
|
||||||
self.assertEqual((case.units_w, case.units_h), (1, 3))
|
|
||||||
|
|
||||||
wide = self.make_case(units_w=4, units_h=1)
|
case.rotate_footprint()
|
||||||
self.assertFalse(wide.fits_trailer_height())
|
self.assertEqual((case.units_w, case.units_h), (2, 1))
|
||||||
|
self.assertEqual(case.texture, (1, 2)) # Textur bleibt
|
||||||
|
self.assertTrue(case.is_rotated)
|
||||||
|
|
||||||
|
def test_rotate_respects_trailer_height(self) -> None:
|
||||||
|
self.assertTrue(self.make_case(units_w=3, units_h=1).fits_trailer_height())
|
||||||
|
self.assertFalse(self.make_case(units_w=4, units_h=1).fits_trailer_height())
|
||||||
|
|
||||||
|
|
||||||
class TrailerTests(unittest.TestCase):
|
class TrailerTests(unittest.TestCase):
|
||||||
@@ -183,11 +214,10 @@ class TrailerTests(unittest.TestCase):
|
|||||||
# Ein flacher Koffer am Boden hat noch Platz darüber -> darf Plattform sein.
|
# Ein flacher Koffer am Boden hat noch Platz darüber -> darf Plattform sein.
|
||||||
ground = self.make_case(4, 1)
|
ground = self.make_case(4, 1)
|
||||||
self.trailer.stack_at_wall(ground)
|
self.trailer.stack_at_wall(ground)
|
||||||
self.assertEqual(ground.y, config.TRAILER_Y + config.TRAILER_HEIGHT - ground.height)
|
|
||||||
self.assertIs(self.trailer.find_platform(self.make_case(1, 1)), ground)
|
self.assertIs(self.trailer.find_platform(self.make_case(1, 1)), ground)
|
||||||
|
|
||||||
def test_no_platform_when_case_reaches_top(self) -> None:
|
def test_no_platform_when_case_reaches_top(self) -> None:
|
||||||
# Ein 3 Einheiten hoher Koffer endet an der Oberkante -> kein Platz darüber.
|
# 3 Einheiten hoch = Oberkante des Aufliegers -> kein Platz darüber.
|
||||||
ground = self.make_case(2, 3)
|
ground = self.make_case(2, 3)
|
||||||
self.trailer.stack_at_wall(ground)
|
self.trailer.stack_at_wall(ground)
|
||||||
self.assertEqual(ground.y, config.TRAILER_Y)
|
self.assertEqual(ground.y, config.TRAILER_Y)
|
||||||
@@ -223,10 +253,40 @@ class TrailerTests(unittest.TestCase):
|
|||||||
self.assertTrue(self.trailer.overflow())
|
self.assertTrue(self.trailer.overflow())
|
||||||
|
|
||||||
|
|
||||||
|
class MenuTests(unittest.TestCase):
|
||||||
|
def test_navigation_wraps_around(self) -> None:
|
||||||
|
menu = DifficultyMenu(None, selected=DIFFICULTIES[1])
|
||||||
|
self.assertIs(menu.selected, DIFFICULTIES[1])
|
||||||
|
|
||||||
|
self.assertIsNone(menu.handle(Action.LEFT))
|
||||||
|
self.assertIs(menu.selected, DIFFICULTIES[0])
|
||||||
|
|
||||||
|
menu.handle(Action.LEFT) # Umlauf ans Ende
|
||||||
|
self.assertIs(menu.selected, DIFFICULTIES[-1])
|
||||||
|
|
||||||
|
menu.handle(Action.RIGHT) # Umlauf an den Anfang
|
||||||
|
self.assertIs(menu.selected, DIFFICULTIES[0])
|
||||||
|
|
||||||
|
def test_confirm_returns_selection(self) -> None:
|
||||||
|
menu = DifficultyMenu(None, selected=DIFFICULTIES[2])
|
||||||
|
self.assertIs(menu.handle(Action.PRIMARY), DIFFICULTIES[2])
|
||||||
|
|
||||||
|
def test_other_actions_do_not_select(self) -> None:
|
||||||
|
menu = DifficultyMenu(None)
|
||||||
|
self.assertIsNone(menu.handle(Action.UP))
|
||||||
|
self.assertIsNone(menu.handle(Action.DOWN))
|
||||||
|
self.assertIsNone(menu.handle(Action.BACK))
|
||||||
|
|
||||||
|
|
||||||
class GameTests(unittest.TestCase):
|
class GameTests(unittest.TestCase):
|
||||||
def make_game(self, sequence_length: int = 30) -> Game:
|
def make_game(self, sequence_length: int = 30, difficulty=None) -> Game:
|
||||||
sequences = [[CaseSpec(2, 1)] * sequence_length]
|
sequences = [[CaseSpec(2, 1)] * sequence_length]
|
||||||
return Game(None, sequences, name="Test", rng=random.Random(7), submit_scores=False)
|
return Game(
|
||||||
|
None,
|
||||||
|
sequences,
|
||||||
|
difficulty=difficulty or DIFFICULTIES[2],
|
||||||
|
rng=random.Random(7),
|
||||||
|
)
|
||||||
|
|
||||||
def update_until(self, game: Game, predicate, steps: int = 5000) -> bool:
|
def update_until(self, game: Game, predicate, steps: int = 5000) -> bool:
|
||||||
for _ in range(steps):
|
for _ in range(steps):
|
||||||
@@ -253,23 +313,29 @@ class GameTests(unittest.TestCase):
|
|||||||
game.trailer.wall_x, config.TRAILER_X + 2 * config.UNIT_LENGTH
|
game.trailer.wall_x, config.TRAILER_X + 2 * config.UNIT_LENGTH
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_rotation_turns_the_case(self) -> None:
|
def test_rotation_keeps_texture(self) -> None:
|
||||||
game = self.make_game()
|
game = Game(
|
||||||
|
None,
|
||||||
|
[[CaseSpec(1, 2)] * 5],
|
||||||
|
difficulty=DIFFICULTIES[2],
|
||||||
|
rng=random.Random(1),
|
||||||
|
)
|
||||||
case = game.current
|
case = game.current
|
||||||
case.x = float(game.trailer.wall_x + config.SNAP_THRESHOLD + 1)
|
case.x = float(game.trailer.wall_x + config.SNAP_THRESHOLD + 1)
|
||||||
case.allow_snap = True
|
case.allow_snap = True
|
||||||
game.handle(Action.LEFT)
|
game.handle(Action.LEFT)
|
||||||
self.update_until(game, lambda: game.animation is None)
|
self.update_until(game, lambda: game.animation is None)
|
||||||
self.assertEqual((case.units_w, case.units_h), (1, 2))
|
self.assertEqual((case.units_w, case.units_h), (2, 1))
|
||||||
|
self.assertEqual(case.texture, (1, 2))
|
||||||
|
self.assertTrue(case.is_rotated)
|
||||||
self.assertFalse(case.can_rotate)
|
self.assertFalse(case.can_rotate)
|
||||||
|
|
||||||
def test_rotation_ignored_for_wide_cases(self) -> None:
|
def test_rotation_ignored_for_wide_cases(self) -> None:
|
||||||
game = Game(
|
game = Game(
|
||||||
None,
|
None,
|
||||||
[[CaseSpec(4, 1)] * 5],
|
[[CaseSpec(4, 1)] * 5],
|
||||||
name="Test",
|
difficulty=DIFFICULTIES[2],
|
||||||
rng=random.Random(1),
|
rng=random.Random(1),
|
||||||
submit_scores=False,
|
|
||||||
)
|
)
|
||||||
case = game.current
|
case = game.current
|
||||||
case.allow_snap = True
|
case.allow_snap = True
|
||||||
@@ -287,14 +353,18 @@ class GameTests(unittest.TestCase):
|
|||||||
self.assertFalse(game.trailer.stack[0].snapped)
|
self.assertFalse(game.trailer.stack[0].snapped)
|
||||||
self.assertGreater(game.shake, 0)
|
self.assertGreater(game.shake, 0)
|
||||||
|
|
||||||
|
def test_easy_is_slower_than_hard(self) -> None:
|
||||||
|
easy = self.make_game(difficulty=DIFFICULTIES[0])
|
||||||
|
hard = self.make_game(difficulty=DIFFICULTIES[2])
|
||||||
|
for _ in range(30):
|
||||||
|
easy.update(1 / 60)
|
||||||
|
hard.update(1 / 60)
|
||||||
|
self.assertGreater(easy.current.x, hard.current.x)
|
||||||
|
|
||||||
def test_game_ends_when_trailer_overflows(self) -> None:
|
def test_game_ends_when_trailer_overflows(self) -> None:
|
||||||
game = self.make_game(sequence_length=40)
|
game = self.make_game(sequence_length=40)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
self.update_until(
|
self.update_until(game, lambda: game.state is State.FINISHED, steps=20000)
|
||||||
game,
|
|
||||||
lambda: game.state is State.GAME_OVER,
|
|
||||||
steps=20000,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
self.assertGreater(game.percent, 0)
|
self.assertGreater(game.percent, 0)
|
||||||
self.assertLessEqual(game.percent, 100)
|
self.assertLessEqual(game.percent, 100)
|
||||||
@@ -302,47 +372,102 @@ class GameTests(unittest.TestCase):
|
|||||||
def test_game_ends_when_sequence_is_exhausted(self) -> None:
|
def test_game_ends_when_sequence_is_exhausted(self) -> None:
|
||||||
game = self.make_game(sequence_length=3)
|
game = self.make_game(sequence_length=3)
|
||||||
for _ in range(20000):
|
for _ in range(20000):
|
||||||
if game.state is State.GAME_OVER:
|
if game.state is State.FINISHED:
|
||||||
break
|
break
|
||||||
if game.current is not None and game.current.allow_snap:
|
if game.current is not None and game.current.allow_snap:
|
||||||
game.handle(Action.PRIMARY)
|
game.handle(Action.PRIMARY)
|
||||||
game.update(1 / 60)
|
game.update(1 / 60)
|
||||||
self.assertIs(State.GAME_OVER, game.state)
|
self.assertIs(State.FINISHED, game.state)
|
||||||
self.assertEqual(len(game.trailer.stack), 3)
|
self.assertEqual(len(game.trailer.stack), 3)
|
||||||
|
|
||||||
def test_back_quits_and_primary_confirms_game_over(self) -> None:
|
def test_back_quits(self) -> None:
|
||||||
|
game = self.make_game()
|
||||||
|
self.assertIs(game.handle(Action.BACK), HandleResult.QUIT)
|
||||||
|
|
||||||
|
def test_primary_after_finish_starts_new_round(self) -> None:
|
||||||
game = self.make_game(sequence_length=2)
|
game = self.make_game(sequence_length=2)
|
||||||
self.assertTrue(game.handle(Action.PRIMARY))
|
for _ in range(20000):
|
||||||
self.assertFalse(game.handle(Action.BACK))
|
if game.state is State.FINISHED:
|
||||||
|
break
|
||||||
|
if game.current is not None and game.current.allow_snap:
|
||||||
|
game.handle(Action.PRIMARY)
|
||||||
|
game.update(1 / 60)
|
||||||
|
self.assertIs(game.state, State.FINISHED)
|
||||||
|
self.assertIs(game.handle(Action.PRIMARY), HandleResult.NEW_ROUND)
|
||||||
|
|
||||||
def test_game_over_lines_mention_player(self) -> None:
|
def test_result_lines_show_difficulty_and_percent(self) -> None:
|
||||||
game = self.make_game(sequence_length=1)
|
game = self.make_game(difficulty=DIFFICULTIES[2])
|
||||||
game.percent = 80
|
game.percent = 82
|
||||||
lines = game.game_over_lines()
|
lines = game.result_lines()
|
||||||
self.assertIn("Test", lines[0])
|
self.assertIn("Hard", lines[0])
|
||||||
self.assertIn("80%", lines[0])
|
self.assertIn("82%", lines[0])
|
||||||
|
self.assertTrue(any("ESC" in line for line in lines))
|
||||||
|
|
||||||
|
|
||||||
class StorageTests(unittest.TestCase):
|
class RenderTests(unittest.TestCase):
|
||||||
def test_roundtrip_in_runtime_dir(self) -> None:
|
"""Rendert je ein Bild von Intro, Menü und Spiel (ohne Fenster).
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
|
||||||
os.environ["HAG_RUNTIME_DIR"] = tmp
|
|
||||||
try:
|
|
||||||
self.assertIsNone(storage.load_last_player())
|
|
||||||
storage.save_last_player(" Anna ")
|
|
||||||
self.assertEqual(storage.load_last_player(), "Anna")
|
|
||||||
self.assertTrue((Path(tmp) / storage.LAST_PLAYER_FILE).exists())
|
|
||||||
finally:
|
|
||||||
del os.environ["HAG_RUNTIME_DIR"]
|
|
||||||
|
|
||||||
def test_empty_name_is_ignored(self) -> None:
|
Fängt fehlende config-Konstanten und Zeichenfehler ab, die reine
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
Logiktests nicht bemerken.
|
||||||
os.environ["HAG_RUNTIME_DIR"] = tmp
|
"""
|
||||||
try:
|
|
||||||
storage.save_last_player(" ")
|
@classmethod
|
||||||
self.assertIsNone(storage.load_last_player())
|
def setUpClass(cls) -> None:
|
||||||
finally:
|
import pygame
|
||||||
del os.environ["HAG_RUNTIME_DIR"]
|
|
||||||
|
from loader_game.assets import Assets
|
||||||
|
|
||||||
|
pygame.mixer.pre_init(44100, -16, 2, 512)
|
||||||
|
pygame.init()
|
||||||
|
cls.surface = pygame.display.set_mode(
|
||||||
|
(config.SCREEN_WIDTH, config.SCREEN_HEIGHT)
|
||||||
|
)
|
||||||
|
cls.assets = Assets()
|
||||||
|
cls.assets.load()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls) -> None:
|
||||||
|
import pygame
|
||||||
|
|
||||||
|
pygame.quit()
|
||||||
|
|
||||||
|
def make_game(self) -> Game:
|
||||||
|
return Game(
|
||||||
|
self.assets,
|
||||||
|
[[CaseSpec(3, 1), CaseSpec(1, 2), CaseSpec(2, 2)]],
|
||||||
|
difficulty=DIFFICULTIES[1],
|
||||||
|
rng=random.Random(3),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_intro_renders(self) -> None:
|
||||||
|
from loader_game.intro import Intro
|
||||||
|
|
||||||
|
intro = Intro(self.assets, show_instruction=True)
|
||||||
|
intro.draw(self.surface)
|
||||||
|
intro.handle(Action.PRIMARY)
|
||||||
|
for _ in range(200):
|
||||||
|
intro.update(1 / 60)
|
||||||
|
intro.draw(self.surface)
|
||||||
|
|
||||||
|
def test_menu_renders(self) -> None:
|
||||||
|
menu = DifficultyMenu(self.assets, selected=DIFFICULTIES[0])
|
||||||
|
menu.handle(Action.RIGHT)
|
||||||
|
menu.draw(self.surface)
|
||||||
|
|
||||||
|
def test_game_and_result_render(self) -> None:
|
||||||
|
game = self.make_game()
|
||||||
|
game.draw(self.surface)
|
||||||
|
game.percent = 61.5
|
||||||
|
game.state = State.FINISHED
|
||||||
|
game.draw(self.surface)
|
||||||
|
|
||||||
|
def test_rotated_case_draws_something(self) -> None:
|
||||||
|
from loader_game import scene
|
||||||
|
|
||||||
|
case = Case(units_w=1, units_h=2, x=200.0, y=400.0)
|
||||||
|
case.rotate_footprint()
|
||||||
|
self.assertTrue(case.is_rotated)
|
||||||
|
scene.draw_case(self.surface, self.assets, case)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in new issue
Block a user