From 441bdb440c9ea3263d7d3deb1c27b3e2d07f355b Mon Sep 17 00:00:00 2001 From: Project Bridge Date: Sun, 1 Nov 2026 11:49:44 +0000 Subject: [PATCH] v1.3: Ablauf gefixt. Tutorial und Startsequenz waren in einer Klasse - jetzt getrennt. Reihenfolge immer: Anleitung -> Moduswahl -> Startsequenz -> Spiel -> Abschluss (10s) -> Anleitung. 57 Tests. --- README.md | 6 +-- docs/ANALYSE.md | 16 +++++++ loader_game/intro.py | 73 +++++++++++++++++-------------- main.py | 100 +++++++++++++++++++++++-------------------- tests/test_logic.py | 31 ++++++++++---- 5 files changed, 136 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index ace0f6c..af4a8ee 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ gezeichnet, nie durch eine andere Textur ersetzt. ## Projektstruktur ``` -main.py Einstiegspunkt (Phasen: Intro → Menü → Runde) +main.py Einstiegspunkt (Phasen: Anleitung → Moduswahl → Start → Spiel) run.sh / run.command Start inkl. venv + Installation requirements.txt pygame case_sequences.txt Koffersequenzen (Datenbestand) @@ -85,7 +85,7 @@ loader_game/ input_map.py Tastatur/Controller → Action scene.py Zeichnen menu.py Stufenauswahl - intro.py Tutorialbild, Ampel, Reissverschluss + intro.py Anleitungsbild (Tutorial) + Startsequenz (Ampel, Reissverschluss) game.py Zustandsmaschine einer Runde tests/test_logic.py 56 Tests (ohne Fenster) 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 (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. ## Eingefrorener Altstand diff --git a/docs/ANALYSE.md b/docs/ANALYSE.md index 3a56cd0..3b7f49e 100644 --- a/docs/ANALYSE.md +++ b/docs/ANALYSE.md @@ -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. | | „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. | + +## 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 → … +``` diff --git a/loader_game/intro.py b/loader_game/intro.py index 95c54cd..4540056 100644 --- a/loader_game/intro.py +++ b/loader_game/intro.py @@ -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 @@ -8,61 +12,65 @@ from . import config, scene from .input_map import Action -class Intro: - """Vorspann vor dem ersten Koffer. +class Tutorial: + """Zeigt das Anleitungsbild und wartet auf A / Space. - Ablauf: Tutorialbild (wartet auf Bestätigung) → drei Punkte mit Ton → - Reissverschluss, der den Auflieger "öffnet". + Kommt vor jeder Runde – auch nach einem Ergebnis. """ - def __init__(self, assets, *, show_instruction: bool = True) -> None: + def __init__(self, assets) -> None: self.assets = assets - 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": - self._start_countdown() + self.done = assets.instruction is None - # ------------------------------------------------------------------ - def _start_countdown(self) -> None: - self.phase = "countdown" + def handle(self, action: Action) -> None: + if action is Action.PRIMARY: + 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.dot_index = 0 + self.finished = False self.assets.play("beep1") def handle(self, action: Action) -> None: - if self.phase == "instruction" and action is Action.PRIMARY: - self._start_countdown() + """Während der Startsequenz wird keine Eingabe gebraucht.""" def update(self, dt: float) -> bool: - """Weiterrechnen; Rückgabe ``True``, wenn der Vorspann fertig ist.""" self.timer += dt - if self.phase == "countdown": + if not self.finished: index = int(self.timer // config.INTRO_DOT_DELAY) if index >= len(config.INTRO_DOT_COLORS): - self.phase = "zip" + self.finished = True self.timer = 0.0 self.assets.play("zip") elif index != self.dot_index: self.dot_index = index self.assets.play(f"beep{index + 1}") return False - if self.phase == "zip": - return self.timer >= config.INTRO_ZIP_DURATION - return False + return self.timer >= config.INTRO_ZIP_DURATION - # ------------------------------------------------------------------ def draw(self, surface: pygame.Surface) -> None: - if self.phase == "instruction": - surface.blit(self.assets.instruction, (0, 0)) - return - scene.draw_top_bar(surface, self.assets, 0.0) scene.draw_background(surface, self.assets) - scene.draw_bottom_bar(surface, self.assets, []) + scene.clear_bottom_bar(surface) - if self.phase == "countdown": + if not self.finished: if 0 <= self.dot_index < len(config.INTRO_DOT_COLORS): pygame.draw.circle( surface, @@ -73,8 +81,9 @@ class Intro: ), config.INTRO_DOT_RADIUS, ) - elif self.phase == "zip": - progress = min(1.0, self.timer / config.INTRO_ZIP_DURATION) if config.INTRO_ZIP_DURATION else 1.0 + else: + duration = config.INTRO_ZIP_DURATION or 1.0 + progress = min(1.0, self.timer / duration) width = int(config.TRAILER_WIDTH * progress) pygame.draw.rect( surface, diff --git a/main.py b/main.py index 6058f5b..d2e53dc 100755 --- a/main.py +++ b/main.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 """Einstiegspunkt des Habegger Loader Games. -Ablauf: Tutorialbild (nur beim Programmstart) → Stufenauswahl → Ampel-Start → -Runde → Ergebnis ("Nochmals?") → Stufenauswahl → … +Ablauf – immer in dieser Reihenfolge, auch nach jedem Ergebnis:: + + Anleitung → Moduswahl → Startsequenz (Ampel + Reissverschluss) → Spiel + → Abschluss (Prozent, Spruch, 10 s Cooldown) → Anleitung → … Beendet wird ausschliesslich mit ``Esc`` (bzw. Controller-Taste B). @@ -27,15 +29,16 @@ from loader_game.assets import Assets 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.intro import StartSequence, Tutorial 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 + TUTORIAL = "tutorial" # Anleitungsbild, wartet auf A/Space + MENU = "menu" # Stufenauswahl + START = "start" # Ampel + Reissverschluss + PLAY = "play" # laufende Runde def parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -100,7 +103,6 @@ def main(argv: list[str] | None = None) -> int: for line in controller_hint_lines(): print(line) - print(assets.audio_info()) sequences, warnings = load_sequences() @@ -112,10 +114,10 @@ def main(argv: list[str] | None = None) -> int: round_number = 0 previous_sequence: int | None = None - phase = Phase.INTRO - after_intro = Phase.MENU # nach dem Tutorial kommt die Stufenauswahl - intro: Intro | None = Intro(assets, show_instruction=True) + phase = Phase.TUTORIAL + tutorial: Tutorial | None = Tutorial(assets) menu: DifficultyMenu | None = None + start: StartSequence | None = None game: Game | None = None def apply_action(action: Action) -> bool: @@ -123,22 +125,25 @@ def main(argv: list[str] | None = None) -> int: 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: return False - if phase is Phase.INTRO and intro is not None: - intro.handle(action) + if phase is Phase.TUTORIAL and tutorial is not None: + tutorial.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 + start = StartSequence(assets) + phase = Phase.START + return True + + if phase is Phase.START and start is not None: + start.handle(action) return True if phase is Phase.PLAY and game is not None: @@ -146,47 +151,50 @@ def main(argv: list[str] | None = None) -> int: if result is HandleResult.QUIT: return False if result is HandleResult.NEW_ROUND: - # Wie beim Programmstart: Anleitung -> Stufenauswahl -> Ampel - intro = Intro(assets, show_instruction=True) - after_intro = Phase.MENU - menu = None - phase = Phase.INTRO + tutorial = Tutorial(assets) + phase = Phase.TUTORIAL return True def advance(dt: float) -> None: """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.TUTORIAL and tutorial is not None: + if tutorial.update(dt): + tutorial = None + 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( + assets, + sequences, + difficulty=difficulty, + rng=rng, + previous_sequence=previous_sequence, + ) + previous_sequence = game.sequence_index + round_number += 1 + first = ", ".join(spec.label for spec in game.sequence[:5]) + print( + f"Runde {round_number}: Sequenz {game.sequence_index + 1}/{len(sequences)} " + f"({len(game.sequence)} Koffer, Start: {first} …), " + f"Stufe {difficulty.label}" + ) + phase = Phase.PLAY - 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, - previous_sequence=previous_sequence, - ) - previous_sequence = game.sequence_index - round_number += 1 - start = ", ".join(spec.label for spec in game.sequence[:5]) - print( - f"Runde {round_number}: Sequenz {game.sequence_index + 1}/{len(sequences)} " - f"({len(game.sequence)} Koffer, Start: {start} …), " - f"Stufe {difficulty.label}" - ) - 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) + if phase is Phase.TUTORIAL and tutorial is not None: + tutorial.draw(screen) elif phase is Phase.MENU and menu is not None: 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: game.draw(screen) pygame.display.flip() diff --git a/tests/test_logic.py b/tests/test_logic.py index cd8d983..cbb4264 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -19,6 +19,7 @@ from loader_game.case import Case 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.intro import StartSequence, Tutorial from loader_game.menu import DifficultyMenu from loader_game.sayings import saying_for from loader_game.sequences import CaseSpec, load_sequences, parse_sequences @@ -485,7 +486,7 @@ class SequenceChoiceTests(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 Logiktests nicht bemerken. @@ -519,15 +520,27 @@ class RenderTests(unittest.TestCase): rng=random.Random(3), ) - def test_intro_renders(self) -> None: - from loader_game.intro import Intro + def test_tutorial_renders_and_waits(self) -> None: + 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) - intro.draw(self.surface) - intro.handle(Action.PRIMARY) - for _ in range(200): - intro.update(1 / 60) - intro.draw(self.surface) + def test_start_sequence_renders_and_finishes(self) -> None: + start = StartSequence(self.assets) + start.draw(self.surface) + elapsed = 0.0 + while not start.update(0.05) and elapsed < 10: + 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: menu = DifficultyMenu(self.assets, selected=DIFFICULTIES[0])