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
```
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
+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. |
| „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 → …
```
+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
@@ -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
# ------------------------------------------------------------------
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,
+39 -31
View File
@@ -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,14 +29,15 @@ 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
TUTORIAL = "tutorial" # Anleitungsbild, wartet auf A/Space
MENU = "menu" # Stufenauswahl
START = "start" # Ampel + Reissverschluss
PLAY = "play" # laufende Runde
@@ -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,21 +151,23 @@ 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.INTRO and intro is not None:
if intro.update(dt):
intro = None
if after_intro is Phase.PLAY:
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,
@@ -170,23 +177,24 @@ def main(argv: list[str] | None = None) -> int:
)
previous_sequence = game.sequence_index
round_number += 1
start = ", ".join(spec.label for spec in game.sequence[:5])
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: {start} …), "
f"({len(game.sequence)} Koffer, Start: {first} …), "
f"Stufe {difficulty.label}"
)
else:
menu = DifficultyMenu(assets, selected=difficulty)
phase = after_intro
phase = Phase.PLAY
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()
+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.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])