v1.1: Stufen Easy/Normal/Hard mit Auswahlmenü (Hard = altes Tempo). Koffer behalten beim Drehen ihre Textur. Name/Punkteliste entfernt, nur Stufe + Prozent. Endlos: Ergebnis -> "Nochmals?" -> Stufenauswahl, Ende nur per Esc. storage.py und net.py gelöscht, 48 Tests.

This commit is contained in:
Project Bridge committed 2026-11-01 10:48:52 +00:00
1 parent de550e1d4d
commit d970185e75
15 files changed
+606 -335

No files matched your search

+2 -2
View File
@@ -6,8 +6,8 @@ old_version/
__pycache__/
*.py[cod]
# Laufzeitdaten (Spielername, lokale Ergebnisse, Logs)
runtime/
# Lokale Logs
# (keine Laufzeitdaten mehr)
*.log
# Editor- und OS-Kram
+39 -44
View File
@@ -1,8 +1,8 @@
# 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.
müssen im Auflieger möglichst dicht gestapelt werden. Angezeigt wird am Ende
die Stufe und die erreichte 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
@@ -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`**
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 |
| `--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
| 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 |
| Auf einen Koffer obendrauf setzen | `↑` | Y / D-Pad hoch |
| Spiel beenden | `Esc` | B |
| 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).
Ein gedrehter Koffer behält seine Grafik – er wird nur um 90° gedreht
gezeichnet, nie durch eine andere Textur ersetzt.
## Projektstruktur
```
main.py Einstiegspunkt (Fenster, Hauptschleife, CLI)
main.py Einstiegspunkt (Phasen: Intro → Menü → Runde)
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
config.py Masse, Farben, Texte, Pfade
difficulty.py Stufen Easy/Normal/Hard (Tempo)
assets.py Bilder/Sounds/Fonts laden und cachen
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)
input_map.py Tastatur/Controller → Action
scene.py Zeichnen
menu.py Stufenauswahl
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)
game.py Zustandsmaschine einer Runde
tests/test_logic.py 48 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.
Alle Werte stehen in `loader_game/config.py` (Layout, Farben, Texte, Tempo der
Animationen) und `loader_game/difficulty.py` (Einlaufgeschwindigkeit der drei
Stufen). Gezeichnet wird immer in der virtuellen Auflösung 1920×1080;
`pygame.SCALED` skaliert automatisch auf die tatsächliche Fenstergrösse
(Retina, 1440×900, 4K …).
## Sequenzdatei
@@ -109,23 +108,19 @@ Ungültige Zeilen werden beim Start mit Meldung übersprungen.
.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).
Getestet werden Sequenz-Parsing, Layout-Invarianten, Kofferbewegung
(inkl. Stufen-Tempo), Stapelregeln, Menü-Navigation, komplette Spieldurchläufe
bis zum Ergebnis sowie das Rendern von Intro, Menü, Spiel und Ergebnis.
Kein Fenster, kein Ton nötig.
## Eingefrorener Altstand
`old_version/` enthält die Dateien vor dem Umbau – ebenfalls in `.gitignore`,
also **nicht** versioniert. Details: `old_version/FROZEN.md`.
`old_version/` enthält die Dateien vor dem Umbau – 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.
Tastatur) – falls wieder eine Personalisierung gewünscht ist.
2. Entscheiden, ob und wohin Ergebnisse gesendet werden (Punkteliste ist
derzeit bewusst nicht vorhanden).
+12
View File
@@ -82,3 +82,15 @@ qr_scan.py ──► last_user.txt ──► subprocess: python loader_game.
3. Entscheiden, ob der Punkte-Upload wieder aktiv genutzt wird – und wohin.
4. `images/*.png` haben ein defektes sRGB-Profil (libpng-Warnung beim Laden,
rein kosmetisch).
## 6. Änderungen nach dem ersten Test (v1.1)
Rückmeldungen aus dem ersten Durchlauf auf dem MacBook und was daraus wurde:
| Rückmeldung | Umsetzung |
| --- | --- |
| „läuft viel zu schnell" | Stufen **Easy / Normal / Hard** (`difficulty.py`), Hard entspricht exakt dem Altstand-Tempo (720/240 px pro Sekunde). Auswahl im Startmenü mit `←`/`→` und A/Space; das Tempo steht jetzt an einer Stelle. |
| „beim Tippen plötzlich andere Textur" | Der Koffer merkt sich in `Case.texture` die Orientierung, in der er eingelaufen ist. Gedreht wird nur die Grafik (90°), nie gegen eine andere Bilddatei getauscht. |
| „Spieler klingt doof" | Name und Punkteliste komplett entfernt (`storage.py`, `net.py`, `--name` gelöscht). Angezeigt werden nur noch Stufe und Beladung in Prozent. |
| „soll nicht enden, sondern fragen: nochmals?" | Neue Phasenkette in `main.py`: Tutorial → Menü → Ampel → Runde → Ergebnis → Menü → … Beendet wird nur mit `Esc`/B. `Game.handle()` liefert dafür `HandleResult.CONTINUE / NEW_ROUND / QUIT`. |
| „lädst du von einer Map/CSV?" | Ja: `case_sequences.txt` im Projektwurzelverzeichnis, 6 Sequenzen als `breite,höhe,rotiert`. Pro Runde wird zufällig eine Sequenz gezogen. |
+6 -6
View File
@@ -2,17 +2,17 @@
Aufbau (jedes Modul hat genau eine Aufgabe):
* :mod:`loader_game.config` – alle Masse, Farben, Tempi, Pfade
* :mod:`loader_game.config` – alle Masse, Farben, Texte, Pfade
* :mod:`loader_game.difficulty` – Stufen Easy/Normal/Hard (Tempo)
* :mod:`loader_game.assets` – Bilder, Sounds, Fonts laden und cachen
* :mod:`loader_game.sequences` – Einlesen von ``case_sequences.txt``
* :mod:`loader_game.case` – der Koffer (Geometrie und Bewegung)
* :mod:`loader_game.case` – der Koffer (Geometrie, Textur, Bewegung)
* :mod:`loader_game.trailer` – Stapelregeln im Auflieger (reine Logik)
* :mod:`loader_game.input_map` – Tastatur/Controller → abstrakte Aktionen
* :mod:`loader_game.scene` – Zeichnen
* :mod:`loader_game.menu` – Stufenauswahl
* :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
* :mod:`loader_game.game` – Zustandsmaschine einer Runde
"""
__version__ = "1.0.0"
__version__ = "1.1.0"
+24 -6
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from dataclasses import dataclass
from . import config
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
@dataclass
@@ -15,9 +16,11 @@ 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).
Kasten-Einheiten. Eine Drehung tauscht die beiden Werte.
``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
@@ -25,11 +28,16 @@ class Case:
x: float
y: float
color: tuple[int, int, int] = config.CASE_COLORS[0]
texture: tuple[int, int] | None = None
placed: bool = False
snapped: bool = False
allow_snap: bool = False
can_rotate: bool = True
def __post_init__(self) -> None:
if self.texture is None:
self.texture = (self.units_w, self.units_h)
# -- Geometrie ------------------------------------------------------
@property
def width(self) -> float:
@@ -47,16 +55,26 @@ class Case:
def bottom(self) -> float:
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 -------------------------------------------------------
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."""
if self.placed:
return
distance = self.x - wall_x
speed = (
config.CASE_SPEED_FAST
difficulty.speed_fast
if distance > config.FAST_TO_SLOW_DISTANCE
else config.CASE_SPEED_SLOW
else difficulty.speed_slow
)
self.x -= speed * dt
if self.x <= wall_x + config.SNAP_THRESHOLD:
+21 -20
View File
@@ -4,18 +4,18 @@ Alle Geometrieangaben beziehen sich auf eine virtuelle Auflösung von
1920x1080. Das Fenster wird per ``pygame.SCALED`` automatisch auf die
tatsächliche Fenstergrösse skaliert – dadurch sieht das Spiel auf einem
MacBook (Retina, 1440x900, …) genauso aus wie auf einem 1080p-Monitor.
Die Spielgeschwindigkeit steht in :mod:`loader_game.difficulty`.
"""
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"
@@ -66,10 +66,8 @@ MAX_UNITS_W = 4
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
SNAP_THRESHOLD = 60 # ab hier darf eingerastet werden
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_HINT_SNAP = (0, 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)
CASE_COLORS = ((200, 0, 0), (0, 200, 0), (0, 0, 200))
@@ -125,27 +126,27 @@ PROGRESS_BAR_RADIUS = 10
FONT_SIZE = 48
FONT_SIZE_SMALL = 34
TITLE_TEXT = WINDOW_TITLE
SUBTITLE_TEMPLATE = "Stufe {difficulty}"
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
# Menü (Stufenauswahl)
# --------------------------------------------------------------------------
DEFAULT_PLAYER_NAME = "Spieler"
MAX_PLAYER_NAME_LENGTH = 14
MENU_TITLE = "Stufe wählen"
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"
# 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
# --------------------------------------------------------------------------
# Spielende
# --------------------------------------------------------------------------
RESULT_TEMPLATE = "Stufe {difficulty}: {percent}% Beladung"
RESULT_HINT = "Nochmals? A / SPACE = neue Runde ESC = beenden"
+38
View File
@@ -0,0 +1,38 @@
"""Schwierigkeitsstufen: wie schnell die Koffer einlaufen.
``hard`` entspricht dem Tempo des Altstands (6 bzw. 2 Pixel pro Frame bei
120 fps = 720 bzw. 240 Pixel pro Sekunde).
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Difficulty:
"""Eine Stufe. ``speed_fast`` gilt bis kurz vor der Barriere, dann ``speed_slow``."""
key: str
label: str
speed_fast: float
speed_slow: float
def __str__(self) -> str:
return self.label
DIFFICULTIES: tuple[Difficulty, ...] = (
Difficulty("easy", "Easy", speed_fast=420.0, speed_slow=140.0),
Difficulty("normal", "Normal", speed_fast=570.0, speed_slow=190.0),
Difficulty("hard", "Hard", speed_fast=720.0, speed_slow=240.0),
)
DEFAULT_DIFFICULTY: Difficulty = DIFFICULTIES[1]
def by_key(key: str) -> Difficulty:
for difficulty in DIFFICULTIES:
if difficulty.key == key:
return difficulty
return DEFAULT_DIFFICULTY
+39 -40
View File
@@ -8,15 +8,23 @@ from dataclasses import dataclass
from . import config, scene
from .case import Case
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
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"
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
@@ -45,14 +53,12 @@ class Game:
assets,
sequences,
*,
name: str = config.DEFAULT_PLAYER_NAME,
difficulty: Difficulty = DEFAULT_DIFFICULTY,
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.difficulty = difficulty
self.state = State.PLAYING
self.trailer = Trailer()
@@ -64,8 +70,6 @@ class Game:
self.animation: Animation | None = None
self.shake = 0.0
self.percent = 0.0
self.status_line = ""
self.status_is_error = False
self._spawn()
@@ -98,20 +102,21 @@ class Game:
# ------------------------------------------------------------------
# Eingabe
# ------------------------------------------------------------------
def handle(self, action: Action) -> bool:
"""Verarbeitet eine Aktion. Rückgabe ``False`` = Spiel beenden."""
def handle(self, action: Action) -> HandleResult:
if action is Action.BACK:
return False
return HandleResult.QUIT
if self.state is State.GAME_OVER:
return action is not Action.PRIMARY
if self.state is State.FINISHED:
if action is Action.PRIMARY:
return HandleResult.NEW_ROUND
return HandleResult.CONTINUE
if self.animation is not None or self.current is None:
return True
return HandleResult.CONTINUE
case = self.current
if case.placed or not case.allow_snap:
return True
return HandleResult.CONTINUE
if action is Action.PRIMARY:
self._start_snap(case)
@@ -119,7 +124,7 @@ class Game:
self._start_rotate(case)
elif action is Action.UP:
self._start_stack(case)
return True
return HandleResult.CONTINUE
def _start_snap(self, case: Case) -> None:
self._stop_roll()
@@ -179,7 +184,7 @@ class Game:
self._finish()
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:
self._stack_with_gap(case, sound="fail")
return
@@ -258,25 +263,25 @@ class Game:
self._finish()
def _finish(self) -> None:
if self.state is State.GAME_OVER:
if self.state is State.FINISHED:
return
self.state = State.GAME_OVER
self.state = State.FINISHED
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_top_bar(
surface,
assets,
self.trailer.load_percent(),
subtitle=config.SUBTITLE_TEMPLATE.format(difficulty=self.difficulty.label),
)
scene.draw_background(surface, assets)
offset = (0, 0)
@@ -304,22 +309,16 @@ class Game:
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,
)
if self.state is State.FINISHED:
scene.draw_overlay_lines(surface, assets, self.result_lines())
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]
def result_lines(self) -> list[str]:
return [
config.RESULT_TEMPLATE.format(
difficulty=self.difficulty.label, percent=int(self.percent)
),
config.RESULT_HINT,
]
# ------------------------------------------------------------------
# Hilfen
+3 -2
View File
@@ -15,9 +15,10 @@ class Intro:
Reissverschluss, der den Auflieger "öffnet".
"""
def __init__(self, assets) -> None:
def __init__(self, assets, *, show_instruction: bool = True) -> None:
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.dot_index = 0
if self.phase == "countdown":
+100
View File
@@ -0,0 +1,100 @@
"""Startmenü: Schwierigkeitsstufe wählen (Easy / Normal / Hard)."""
from __future__ import annotations
import pygame
from . import config, scene
from .difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, Difficulty
from .input_map import Action
class DifficultyMenu:
"""Drei Kacheln, Auswahl mit ← / →, Start mit A / Space."""
def __init__(
self,
assets,
options: tuple[Difficulty, ...] = DIFFICULTIES,
selected: Difficulty = DEFAULT_DIFFICULTY,
) -> None:
self.assets = assets
self.options = list(options)
self.index = self.options.index(selected) if selected in self.options else 0
# ------------------------------------------------------------------
@property
def selected(self) -> Difficulty:
return self.options[self.index]
def handle(self, action: Action) -> Difficulty | None:
"""Rückgabe: gewählte Stufe, sobald bestätigt wurde – sonst ``None``."""
if action is Action.LEFT:
self.index = (self.index - 1) % len(self.options)
elif action is Action.RIGHT:
self.index = (self.index + 1) % len(self.options)
elif action is Action.PRIMARY:
return self.selected
return None
# ------------------------------------------------------------------
def draw(self, surface: pygame.Surface) -> None:
scene.draw_top_bar(surface, self.assets, 0.0)
scene.draw_background(surface, self.assets)
scene.clear_bottom_bar(surface)
scene.draw_dim(surface, 170)
font = self.assets.font
small = self.assets.font_small or font
title = font.render(config.MENU_TITLE, True, config.COLOR_WHITE)
surface.blit(
title,
(config.SCREEN_WIDTH // 2 - title.get_width() // 2, config.MENU_TOP - 110),
)
total_width = len(self.options) * config.MENU_BOX_WIDTH + (
len(self.options) - 1
) * config.MENU_BOX_GAP
x = (config.SCREEN_WIDTH - total_width) // 2
for index, difficulty in enumerate(self.options):
rect = pygame.Rect(
x, config.MENU_TOP, config.MENU_BOX_WIDTH, config.MENU_BOX_HEIGHT
)
active = index == self.index
pygame.draw.rect(
surface,
config.COLOR_MENU_BOX_ACTIVE if active else config.COLOR_MENU_BOX,
rect,
border_radius=14,
)
pygame.draw.rect(
surface,
config.COLOR_WHITE if active else config.COLOR_MENU_BORDER,
rect,
width=config.MENU_BOX_BORDER,
border_radius=14,
)
label = font.render(
difficulty.label, True, (0, 0, 0) if active else config.COLOR_WHITE
)
surface.blit(
label,
(
rect.centerx - label.get_width() // 2,
rect.centery - label.get_height() // 2,
),
)
x += config.MENU_BOX_WIDTH + config.MENU_BOX_GAP
hint = small.render(config.MENU_HINT, True, config.COLOR_WHITE)
hint_y = config.MENU_TOP + config.MENU_BOX_HEIGHT + 60
band = pygame.Rect(
config.SCREEN_WIDTH // 2 - hint.get_width() // 2 - 30,
hint_y - 10,
hint.get_width() + 60,
hint.get_height() + 20,
)
pygame.draw.rect(surface, config.COLOR_DIM, band, border_radius=10)
surface.blit(hint, (band.x + 30, hint_y))
-47
View File
@@ -1,47 +0,0 @@
"""Optionaler Punkte-Upload.
Standardmässig **deaktiviert**: Es passiert nichts, solange ``HAG_SCORE_URL``
nicht gesetzt ist. Zugangsdaten kommen aus der Umgebung
(``HAG_SCORE_API_KEY``) – nicht aus dem Quellcode.
"""
from __future__ import annotations
import urllib.error
import urllib.parse
import urllib.request
from . import config
def submit_score(
name: str,
percent: float,
*,
url: str | None = None,
api_key: str | None = None,
timeout: float | None = None,
) -> tuple[bool, str]:
"""Sendet das Ergebnis. Rückgabe: ``(erfolgreich, meldung)``."""
endpoint = url if url is not None else config.SCORE_URL
if not endpoint:
return False, ""
payload = urllib.parse.urlencode(
{
"name": name,
"score": int(percent),
"api_key": api_key if api_key is not None else config.SCORE_API_KEY,
}
).encode("utf-8")
try:
request = urllib.request.Request(endpoint, data=payload)
with urllib.request.urlopen(request, timeout=timeout or config.SCORE_TIMEOUT) as response:
body = response.read().decode("utf-8", "replace")
except (urllib.error.URLError, OSError, ValueError) as exc:
return False, f"Punkte konnten nicht gesendet werden: {exc}"
if "success" not in body.lower():
return False, "Punkte konnten nicht gesendet werden (unerwartete Serverantwort)."
return True, "Punkte gesendet."
+41 -19
View File
@@ -12,13 +12,22 @@ from .sequences import CaseSpec
# --------------------------------------------------------------------------
# Hintergrund-Bänder
# --------------------------------------------------------------------------
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(
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))
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)
@@ -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(
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
label = (assets.font_small or assets.font).render(config.NEXT_LABEL, True, config.COLOR_WHITE)
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:
"""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)
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:
pygame.draw.rect(surface, case.color, (x, y, width, height))
return
if case.is_rotated:
image = pygame.transform.rotate(image, -90)
if angle:
rotated = pygame.transform.rotate(image, angle)
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(
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))
"""Dunkler Schleier mit zentrierten Textzeilen (Ergebnis-Anzeige)."""
draw_dim(surface, 220)
center_y = config.SCREEN_HEIGHT // 2
line_gap = 16
total_height = 0
rendered: list[pygame.Surface] = []
total_height = 0
for line in lines:
image = assets.font.render(line, True, config.COLOR_WHITE)
rendered.append(image)
@@ -196,10 +225,3 @@ def draw_overlay_lines(
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),
)
-50
View File
@@ -1,50 +0,0 @@
"""Laufzeitdaten (letzter Spielername).
Alles, was zur Laufzeit geschrieben wird, landet in ``runtime/`` – dieser
Ordner ist in ``.gitignore`` eingetragen. Über die Umgebungsvariable
``HAG_RUNTIME_DIR`` lässt sich der Ort umbiegen (praktisch für Tests).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from . import config
LAST_PLAYER_FILE = "last_player.json"
def runtime_dir() -> Path:
"""Verzeichnis für Laufzeitdaten; wird bei Bedarf angelegt."""
override = os.environ.get("HAG_RUNTIME_DIR")
path = Path(override) if override else config.RUNTIME_DIR
path.mkdir(parents=True, exist_ok=True)
return path
def load_last_player() -> str | None:
try:
data = json.loads((runtime_dir() / LAST_PLAYER_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict):
return None
name = str(data.get("name", "")).strip()
return name or None
def save_last_player(name: str) -> None:
name = name.strip()[: config.MAX_PLAYER_NAME_LENGTH]
if not name:
return
try:
(runtime_dir() / LAST_PLAYER_FILE).write_text(
json.dumps({"name": name}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
except OSError:
# Laufzeitdaten sind nice-to-have – ein Schreibfehler darf das Spiel
# nicht stoppen.
pass
+101 -44
View File
@@ -1,30 +1,43 @@
#!/usr/bin/env python3
"""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::
./run.sh # Fenster, letzter Spielername
./run.sh # Fenster
./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 enum
import random
import sys
import pygame
from loader_game import config, storage
from loader_game import config
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.intro import Intro
from loader_game.menu import DifficultyMenu
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:
parser = argparse.ArgumentParser(description="Habegger Loader Game")
parser.add_argument(
@@ -32,11 +45,6 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
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,
@@ -48,8 +56,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
type=float,
default=None,
metavar="SEKUNDEN",
help="Selbsttest: bestätigt das Intro automatisch, spielt ohne Eingaben "
"und beendet sich nach N Sekunden",
help="Selbsttest: spielt ohne Eingaben und beendet sich nach N Sekunden",
)
return parser.parse_args(argv)
@@ -98,15 +105,72 @@ def main(argv: list[str] | None = None) -> int:
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()
difficulty: Difficulty = DEFAULT_DIFFICULTY
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
smoke_remaining = args.smoke
smoke_timer = 0.0
@@ -114,6 +178,7 @@ def main(argv: list[str] | None = None) -> int:
while running:
dt = min(clock.tick(config.FPS) / 1000.0, 0.1)
# ---------------------------------------------------------- Eingabe
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
@@ -122,47 +187,39 @@ def main(argv: list[str] | None = None) -> int:
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:
if not apply_action(action):
running = False
elif game is not None:
running = game.handle(action)
break
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)
advance(dt)
# Selbsttest: ohne Eingabegeräte mitspielen
if smoke_remaining is not None:
smoke_remaining -= dt
if smoke_remaining <= 0:
running = False
elif game is not None and intro is None:
else:
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)
smoke_timer = 0.3
if phase is Phase.PLAY and game is not None:
demo = _demo_action(game, rng)
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:
intro.draw(screen)
elif game is not None:
game.draw(screen)
pygame.display.flip()
draw()
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(
f"Spiel beendet: Zustand={game.state.value}, "
f"Beladung={percent:.0f}%, Koffer im Auflieger={len(game.trailer.stack)}"
f"Spiel beendet: Stufe={difficulty.label}, Beladung={percent:.0f}%, "
f"Koffer im Auflieger={len(game.trailer.stack)}"
)
pygame.quit()
+180 -55
View File
@@ -9,17 +9,17 @@ 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 import config
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.menu import DifficultyMenu
from loader_game.sequences import CaseSpec, load_sequences, parse_sequences
from loader_game.trailer import Trailer
@@ -78,6 +78,25 @@ class LayoutTests(unittest.TestCase):
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):
def make_case(self, units_w: int = 2, units_h: int = 1, x: float | None = None) -> Case:
return Case(
@@ -100,6 +119,13 @@ class CaseTests(unittest.TestCase):
slow_step = before - case.x
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:
case = self.make_case()
case.move(1 / 60, wall_x=0)
@@ -116,14 +142,19 @@ class CaseTests(unittest.TestCase):
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))
def test_case_keeps_its_texture_when_rotated(self) -> None:
case = self.make_case(units_w=1, units_h=2)
self.assertEqual(case.texture, (1, 2))
self.assertFalse(case.is_rotated)
wide = self.make_case(units_w=4, units_h=1)
self.assertFalse(wide.fits_trailer_height())
case.rotate_footprint()
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):
@@ -183,11 +214,10 @@ class TrailerTests(unittest.TestCase):
# 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.
# 3 Einheiten hoch = Oberkante des Aufliegers -> kein Platz darüber.
ground = self.make_case(2, 3)
self.trailer.stack_at_wall(ground)
self.assertEqual(ground.y, config.TRAILER_Y)
@@ -223,10 +253,40 @@ class TrailerTests(unittest.TestCase):
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):
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]
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:
for _ in range(steps):
@@ -253,23 +313,29 @@ class GameTests(unittest.TestCase):
game.trailer.wall_x, config.TRAILER_X + 2 * config.UNIT_LENGTH
)
def test_rotation_turns_the_case(self) -> None:
game = self.make_game()
def test_rotation_keeps_texture(self) -> None:
game = Game(
None,
[[CaseSpec(1, 2)] * 5],
difficulty=DIFFICULTIES[2],
rng=random.Random(1),
)
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.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)
def test_rotation_ignored_for_wide_cases(self) -> None:
game = Game(
None,
[[CaseSpec(4, 1)] * 5],
name="Test",
difficulty=DIFFICULTIES[2],
rng=random.Random(1),
submit_scores=False,
)
case = game.current
case.allow_snap = True
@@ -287,14 +353,18 @@ class GameTests(unittest.TestCase):
self.assertFalse(game.trailer.stack[0].snapped)
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:
game = self.make_game(sequence_length=40)
self.assertTrue(
self.update_until(
game,
lambda: game.state is State.GAME_OVER,
steps=20000,
)
self.update_until(game, lambda: game.state is State.FINISHED, steps=20000)
)
self.assertGreater(game.percent, 0)
self.assertLessEqual(game.percent, 100)
@@ -302,47 +372,102 @@ class GameTests(unittest.TestCase):
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:
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(State.GAME_OVER, game.state)
self.assertIs(State.FINISHED, game.state)
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)
self.assertTrue(game.handle(Action.PRIMARY))
self.assertFalse(game.handle(Action.BACK))
for _ in range(20000):
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:
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])
def test_result_lines_show_difficulty_and_percent(self) -> None:
game = self.make_game(difficulty=DIFFICULTIES[2])
game.percent = 82
lines = game.result_lines()
self.assertIn("Hard", lines[0])
self.assertIn("82%", lines[0])
self.assertTrue(any("ESC" in line for line in lines))
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"]
class RenderTests(unittest.TestCase):
"""Rendert je ein Bild von Intro, Menü und Spiel (ohne Fenster).
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"]
Fängt fehlende config-Konstanten und Zeichenfehler ab, die reine
Logiktests nicht bemerken.
"""
@classmethod
def setUpClass(cls) -> None:
import pygame
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__":