v1.3: Ablauf gefixt. Tutorial und Startsequenz waren in einer Klasse - jetzt getrennt. Reihenfolge immer: Anleitung -> Moduswahl -> Startsequenz -> Spiel -> Abschluss (10s) -> Anleitung. 57 Tests.

This commit is contained in:
Project Bridge committed 2026-11-01 11:49:44 +00:00
1 parent e3a0c5a57d
commit 441bdb440c
5 files changed
+120 -74

No files matched your search

+3 -3
View File
@@ -70,7 +70,7 @@ gezeichnet, nie durch eine andere Textur ersetzt.
## Projektstruktur ## Projektstruktur
``` ```
main.py Einstiegspunkt (Phasen: Intro → Menü → Runde) main.py Einstiegspunkt (Phasen: Anleitung → Moduswahl → Start → Spiel)
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)
@@ -85,7 +85,7 @@ loader_game/
input_map.py Tastatur/Controller → Action input_map.py Tastatur/Controller → Action
scene.py Zeichnen scene.py Zeichnen
menu.py Stufenauswahl menu.py Stufenauswahl
intro.py Tutorialbild, Ampel, Reissverschluss intro.py Anleitungsbild (Tutorial) + Startsequenz (Ampel, Reissverschluss)
game.py Zustandsmaschine einer Runde game.py Zustandsmaschine einer Runde
tests/test_logic.py 56 Tests (ohne Fenster) tests/test_logic.py 56 Tests (ohne Fenster)
docs/ANALYSE.md Analyse Altstand + Fehlerliste docs/ANALYSE.md Analyse Altstand + Fehlerliste
@@ -121,7 +121,7 @@ Ungültige Zeilen werden beim Start mit Meldung übersprungen.
Getestet werden Sequenz-Parsing, Layout-Invarianten, Kofferbewegung Getestet werden Sequenz-Parsing, Layout-Invarianten, Kofferbewegung
(inkl. Stufen-Tempo), Stapelregeln, Menü-Navigation, komplette Spieldurchläufe (inkl. Stufen-Tempo), Stapelregeln, Menü-Navigation, komplette Spieldurchläufe
bis zum Ergebnis sowie das Rendern von Intro, Menü, Spiel und Ergebnis. bis zum Ergebnis sowie das Rendern von Anleitung, Startsequenz, Menü, Spiel und Ergebnis.
Kein Fenster, kein Ton nötig. Kein Fenster, kein Ton nötig.
## Eingefrorener Altstand ## Eingefrorener Altstand
+16
View File
@@ -105,3 +105,19 @@ Rückmeldungen aus dem ersten Durchlauf auf dem MacBook und was daraus wurde:
| „Verlangsamen und Snap-Fenster früher" | `FAST_TO_SLOW_DISTANCE` 120 → 260 px, neues `SNAP_WINDOW` 160 px (vorher 60). Die Strafe bei einer Kollision bleibt unverändert bei `FAIL_GAP` 60 px. Snap-Animation dauert jetzt entfernungsabhängig 0.08–0.30 s. | | „Verlangsamen und Snap-Fenster früher" | `FAST_TO_SLOW_DISTANCE` 120 → 260 px, neues `SNAP_WINDOW` 160 px (vorher 60). Die Strafe bei einer Kollision bleibt unverändert bei `FAIL_GAP` 60 px. Snap-Animation dauert jetzt entfernungsabhängig 0.08–0.30 s. |
| „Cooldown am Ende" | `RESULT_COOLDOWN = 10` s. Ergebnis bleibt stehen, zeigt „Neue Runde in n s …", A wird erst danach angenommen. Esc beendet jederzeit. | | „Cooldown am Ende" | `RESULT_COOLDOWN = 10` s. Ergebnis bleibt stehen, zeigt „Neue Runde in n s …", A wird erst danach angenommen. Esc beendet jederzeit. |
| „lustige Sprüche" | Neues Modul `sayings.py` mit Stufen (100 % = Vertrag-im-Lager-Spruch, 85+, 70+, 55+, 40+, 20+, darunter). Bewusst jugendtauglich: frech, aber nicht beleidigend – ein Test prüft das. | | „lustige Sprüche" | Neues Modul `sayings.py` mit Stufen (100 % = Vertrag-im-Lager-Spruch, 85+, 70+, 55+, 40+, 20+, darunter). Bewusst jugendtauglich: frech, aber nicht beleidigend – ein Test prüft das. |
## 8. Ablauf-Korrektur (v1.3)
Der Ablauf war doppelt verdrahtet: die Klasse `Intro` enthielt sowohl das
Anleitungsbild als auch die Ampel-/Reissverschluss-Sequenz. Wer die Anleitung
bestätigte, startete damit ungewollt die Startsequenz – sie lief also vor der
Moduswahl und danach noch einmal.
Jetzt sind es zwei getrennte Bausteine (`intro.py`): `Tutorial` (nur Bild,
wartet auf A/Space, keine Töne) und `StartSequence` (Ampel + Reissverschluss).
Die Phasenkette im Hauptprogramm ist damit eindeutig und gilt für **jede**
Runde:
```
ANLEITUNG → MODUSWAHL → STARTSEQUENZ → SPIEL → ABSCHLUSS (10 s) → ANLEITUNG → …
```
+40 -31
View File
@@ -1,4 +1,8 @@
"""Tutorialbild, Ampel-Startsequenz und Reissverschluss-Effekt.""" """Vorspann: Anleitungsbild und Startsequenz (Ampel + Reissverschluss).
Bewusst zwei getrennte Bausteine, damit die Reihenfolge im Hauptprogramm
eindeutig ist: **Anleitung → Moduswahl → Startsequenz → Spiel**.
"""
from __future__ import annotations from __future__ import annotations
@@ -8,61 +12,65 @@ from . import config, scene
from .input_map import Action from .input_map import Action
class Intro: class Tutorial:
"""Vorspann vor dem ersten Koffer. """Zeigt das Anleitungsbild und wartet auf A / Space.
Ablauf: Tutorialbild (wartet auf Bestätigung) → drei Punkte mit Ton → Kommt vor jeder Runde – auch nach einem Ergebnis.
Reissverschluss, der den Auflieger "öffnet".
""" """
def __init__(self, assets, *, show_instruction: bool = True) -> None: def __init__(self, assets) -> None:
self.assets = assets self.assets = assets
show_tutorial = show_instruction and assets.instruction is not None self.done = assets.instruction is None
self.phase = "instruction" if show_tutorial else "countdown"
self.timer = 0.0
self.dot_index = 0
if self.phase == "countdown":
self._start_countdown()
# ------------------------------------------------------------------ def handle(self, action: Action) -> None:
def _start_countdown(self) -> None: if action is Action.PRIMARY:
self.phase = "countdown" self.done = True
def update(self, dt: float) -> bool:
return self.done
def draw(self, surface: pygame.Surface) -> None:
if self.assets.instruction is not None:
surface.blit(self.assets.instruction, (0, 0))
return
scene.draw_top_bar(surface, self.assets, 0.0)
scene.draw_background(surface, self.assets)
scene.clear_bottom_bar(surface)
class StartSequence:
"""Ampel (rot/gelb/grün) und Reissverschluss, der den Auflieger öffnet."""
def __init__(self, assets) -> None:
self.assets = assets
self.timer = 0.0 self.timer = 0.0
self.dot_index = 0 self.dot_index = 0
self.finished = False
self.assets.play("beep1") self.assets.play("beep1")
def handle(self, action: Action) -> None: def handle(self, action: Action) -> None:
if self.phase == "instruction" and action is Action.PRIMARY: """Während der Startsequenz wird keine Eingabe gebraucht."""
self._start_countdown()
def update(self, dt: float) -> bool: def update(self, dt: float) -> bool:
"""Weiterrechnen; Rückgabe ``True``, wenn der Vorspann fertig ist."""
self.timer += dt self.timer += dt
if self.phase == "countdown": if not self.finished:
index = int(self.timer // config.INTRO_DOT_DELAY) index = int(self.timer // config.INTRO_DOT_DELAY)
if index >= len(config.INTRO_DOT_COLORS): if index >= len(config.INTRO_DOT_COLORS):
self.phase = "zip" self.finished = True
self.timer = 0.0 self.timer = 0.0
self.assets.play("zip") self.assets.play("zip")
elif index != self.dot_index: elif index != self.dot_index:
self.dot_index = index self.dot_index = index
self.assets.play(f"beep{index + 1}") self.assets.play(f"beep{index + 1}")
return False return False
if self.phase == "zip":
return self.timer >= config.INTRO_ZIP_DURATION return self.timer >= config.INTRO_ZIP_DURATION
return False
# ------------------------------------------------------------------
def draw(self, surface: pygame.Surface) -> None: def draw(self, surface: pygame.Surface) -> None:
if self.phase == "instruction":
surface.blit(self.assets.instruction, (0, 0))
return
scene.draw_top_bar(surface, self.assets, 0.0) scene.draw_top_bar(surface, self.assets, 0.0)
scene.draw_background(surface, self.assets) scene.draw_background(surface, self.assets)
scene.draw_bottom_bar(surface, self.assets, []) scene.clear_bottom_bar(surface)
if self.phase == "countdown": if not self.finished:
if 0 <= self.dot_index < len(config.INTRO_DOT_COLORS): if 0 <= self.dot_index < len(config.INTRO_DOT_COLORS):
pygame.draw.circle( pygame.draw.circle(
surface, surface,
@@ -73,8 +81,9 @@ class Intro:
), ),
config.INTRO_DOT_RADIUS, config.INTRO_DOT_RADIUS,
) )
elif self.phase == "zip": else:
progress = min(1.0, self.timer / config.INTRO_ZIP_DURATION) if config.INTRO_ZIP_DURATION else 1.0 duration = config.INTRO_ZIP_DURATION or 1.0
progress = min(1.0, self.timer / duration)
width = int(config.TRAILER_WIDTH * progress) width = int(config.TRAILER_WIDTH * progress)
pygame.draw.rect( pygame.draw.rect(
surface, surface,
+39 -31
View File
@@ -1,8 +1,10 @@
#!/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 → Ablauf – immer in dieser Reihenfolge, auch nach jedem Ergebnis::
Runde → Ergebnis ("Nochmals?") → Stufenauswahl → …
Anleitung → Moduswahl → Startsequenz (Ampel + Reissverschluss) → Spiel
→ Abschluss (Prozent, Spruch, 10 s Cooldown) → Anleitung → …
Beendet wird ausschliesslich mit ``Esc`` (bzw. Controller-Taste B). Beendet wird ausschliesslich mit ``Esc`` (bzw. Controller-Taste B).
@@ -27,14 +29,15 @@ from loader_game.assets import Assets
from loader_game.difficulty import DEFAULT_DIFFICULTY, Difficulty from loader_game.difficulty import DEFAULT_DIFFICULTY, Difficulty
from loader_game.game import Game, HandleResult, State 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 StartSequence, Tutorial
from loader_game.menu import DifficultyMenu from loader_game.menu import DifficultyMenu
from loader_game.sequences import load_sequences from loader_game.sequences import load_sequences
class Phase(enum.Enum): class Phase(enum.Enum):
INTRO = "intro" # Tutorial und/oder Ampel-Start TUTORIAL = "tutorial" # Anleitungsbild, wartet auf A/Space
MENU = "menu" # Stufenauswahl MENU = "menu" # Stufenauswahl
START = "start" # Ampel + Reissverschluss
PLAY = "play" # laufende Runde PLAY = "play" # laufende Runde
@@ -100,7 +103,6 @@ def main(argv: list[str] | None = None) -> int:
for line in controller_hint_lines(): for line in controller_hint_lines():
print(line) print(line)
print(assets.audio_info()) print(assets.audio_info())
sequences, warnings = load_sequences() sequences, warnings = load_sequences()
@@ -112,10 +114,10 @@ def main(argv: list[str] | None = None) -> int:
round_number = 0 round_number = 0
previous_sequence: int | None = None previous_sequence: int | None = None
phase = Phase.INTRO phase = Phase.TUTORIAL
after_intro = Phase.MENU # nach dem Tutorial kommt die Stufenauswahl tutorial: Tutorial | None = Tutorial(assets)
intro: Intro | None = Intro(assets, show_instruction=True)
menu: DifficultyMenu | None = None menu: DifficultyMenu | None = None
start: StartSequence | None = None
game: Game | None = None game: Game | None = None
def apply_action(action: Action) -> bool: def apply_action(action: Action) -> bool:
@@ -123,22 +125,25 @@ def main(argv: list[str] | None = None) -> int:
Rückgabe ``False`` = Programm beenden. Rückgabe ``False`` = Programm beenden.
""" """
nonlocal phase, intro, menu, game, difficulty, after_intro nonlocal phase, tutorial, menu, start, game, difficulty
if action is Action.BACK: if action is Action.BACK:
return False return False
if phase is Phase.INTRO and intro is not None: if phase is Phase.TUTORIAL and tutorial is not None:
intro.handle(action) tutorial.handle(action)
return True return True
if phase is Phase.MENU and menu is not None: if phase is Phase.MENU and menu is not None:
selected = menu.handle(action) selected = menu.handle(action)
if selected is not None: if selected is not None:
difficulty = selected difficulty = selected
intro = Intro(assets, show_instruction=False) start = StartSequence(assets)
after_intro = Phase.PLAY phase = Phase.START
phase = Phase.INTRO return True
if phase is Phase.START and start is not None:
start.handle(action)
return True return True
if phase is Phase.PLAY and game is not None: if phase is Phase.PLAY and game is not None:
@@ -146,21 +151,23 @@ def main(argv: list[str] | None = None) -> int:
if result is HandleResult.QUIT: if result is HandleResult.QUIT:
return False return False
if result is HandleResult.NEW_ROUND: if result is HandleResult.NEW_ROUND:
# Wie beim Programmstart: Anleitung -> Stufenauswahl -> Ampel tutorial = Tutorial(assets)
intro = Intro(assets, show_instruction=True) phase = Phase.TUTORIAL
after_intro = Phase.MENU
menu = None
phase = Phase.INTRO
return True return True
def advance(dt: float) -> None: def advance(dt: float) -> None:
"""Weiterrechnen und Phasenwechsel ohne Eingabe.""" """Weiterrechnen und Phasenwechsel ohne Eingabe."""
nonlocal phase, intro, menu, game, round_number, previous_sequence nonlocal phase, tutorial, menu, start, game, round_number, previous_sequence
if phase is Phase.INTRO and intro is not None: if phase is Phase.TUTORIAL and tutorial is not None:
if intro.update(dt): if tutorial.update(dt):
intro = None tutorial = None
if after_intro is Phase.PLAY: menu = DifficultyMenu(assets, selected=difficulty)
phase = Phase.MENU
elif phase is Phase.START and start is not None:
if start.update(dt):
start = None
game = Game( game = Game(
assets, assets,
sequences, sequences,
@@ -170,23 +177,24 @@ def main(argv: list[str] | None = None) -> int:
) )
previous_sequence = game.sequence_index previous_sequence = game.sequence_index
round_number += 1 round_number += 1
start = ", ".join(spec.label for spec in game.sequence[:5]) first = ", ".join(spec.label for spec in game.sequence[:5])
print( print(
f"Runde {round_number}: Sequenz {game.sequence_index + 1}/{len(sequences)} " f"Runde {round_number}: Sequenz {game.sequence_index + 1}/{len(sequences)} "
f"({len(game.sequence)} Koffer, Start: {start} …), " f"({len(game.sequence)} Koffer, Start: {first} …), "
f"Stufe {difficulty.label}" f"Stufe {difficulty.label}"
) )
else: phase = Phase.PLAY
menu = DifficultyMenu(assets, selected=difficulty)
phase = after_intro
elif phase is Phase.PLAY and game is not None: elif phase is Phase.PLAY and game is not None:
game.update(dt) game.update(dt)
def draw() -> None: def draw() -> None:
if phase is Phase.INTRO and intro is not None: if phase is Phase.TUTORIAL and tutorial is not None:
intro.draw(screen) tutorial.draw(screen)
elif phase is Phase.MENU and menu is not None: elif phase is Phase.MENU and menu is not None:
menu.draw(screen) menu.draw(screen)
elif phase is Phase.START and start is not None:
start.draw(screen)
elif phase is Phase.PLAY and game is not None: elif phase is Phase.PLAY and game is not None:
game.draw(screen) game.draw(screen)
pygame.display.flip() pygame.display.flip()
+22 -9
View File
@@ -19,6 +19,7 @@ from loader_game.case import Case
from loader_game.difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, by_key from loader_game.difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, by_key
from loader_game.game import Game, HandleResult, State 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.intro import StartSequence, Tutorial
from loader_game.menu import DifficultyMenu from loader_game.menu import DifficultyMenu
from loader_game.sayings import saying_for from loader_game.sayings import saying_for
from loader_game.sequences import CaseSpec, load_sequences, parse_sequences from loader_game.sequences import CaseSpec, load_sequences, parse_sequences
@@ -485,7 +486,7 @@ class SequenceChoiceTests(unittest.TestCase):
class RenderTests(unittest.TestCase): class RenderTests(unittest.TestCase):
"""Rendert je ein Bild von Intro, Menü und Spiel (ohne Fenster). """Rendert je ein Bild von Anleitung, Startsequenz, Menü und Spiel (ohne Fenster).
Fängt fehlende config-Konstanten und Zeichenfehler ab, die reine Fängt fehlende config-Konstanten und Zeichenfehler ab, die reine
Logiktests nicht bemerken. Logiktests nicht bemerken.
@@ -519,15 +520,27 @@ class RenderTests(unittest.TestCase):
rng=random.Random(3), rng=random.Random(3),
) )
def test_intro_renders(self) -> None: def test_tutorial_renders_and_waits(self) -> None:
from loader_game.intro import Intro tutorial = Tutorial(self.assets)
tutorial.draw(self.surface)
# Das Tutorial zeigt nur das Bild und wartet - keine Startsequenz.
self.assertFalse(tutorial.update(1 / 60))
self.assertFalse(tutorial.update(1 / 60))
tutorial.handle(Action.PRIMARY)
self.assertTrue(tutorial.update(1 / 60))
intro = Intro(self.assets, show_instruction=True) def test_start_sequence_renders_and_finishes(self) -> None:
intro.draw(self.surface) start = StartSequence(self.assets)
intro.handle(Action.PRIMARY) start.draw(self.surface)
for _ in range(200): elapsed = 0.0
intro.update(1 / 60) while not start.update(0.05) and elapsed < 10:
intro.draw(self.surface) elapsed += 0.05
expected = (
len(config.INTRO_DOT_COLORS) * config.INTRO_DOT_DELAY
+ config.INTRO_ZIP_DURATION
)
self.assertAlmostEqual(elapsed, expected, delta=0.3)
start.draw(self.surface)
def test_menu_renders(self) -> None: def test_menu_renders(self) -> None:
menu = DifficultyMenu(self.assets, selected=DIFFICULTIES[0]) menu = DifficultyMenu(self.assets, selected=DIFFICULTIES[0])