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:
1 parent
e3a0c5a57d
commit
441bdb440c
5 files changed
+120
-74
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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])
|
||||
|
||||
Reference in new issue
Block a user