From e3a0c5a57dd87fc3b182d7f91d0b683c3ab3272f Mon Sep 17 00:00:00 2001 From: Project Bridge Date: Sun, 1 Nov 2026 11:38:16 +0000 Subject: [PATCH] v1.2: Log zeigt gewaehlte Sequenz (nie zweimal dieselbe), Anleitung vor Moduswahl (jede Runde), Roll-Sound-Kanal reserviert (Fix kein Sound), frueheres Abbremsen + Snap-Fenster 160px, 10s Cooldown am Ende, Sprueche nach Prozent (sayings.py), 56 Tests. --- README.md | 19 +++++++-- docs/ANALYSE.md | 11 ++++++ loader_game/assets.py | 21 ++++++++-- loader_game/case.py | 2 +- loader_game/config.py | 10 ++++- loader_game/game.py | 49 +++++++++++++++++++---- loader_game/sayings.py | 68 ++++++++++++++++++++++++++++++++ loader_game/scene.py | 57 ++++++++++++++++++++++----- loader_game/trailer.py | 2 +- main.py | 29 ++++++++++++-- tests/test_logic.py | 88 ++++++++++++++++++++++++++++++++++++++++-- 11 files changed, 319 insertions(+), 37 deletions(-) create mode 100644 loader_game/sayings.py diff --git a/README.md b/README.md index fa800e9..ace0f6c 100644 --- a/README.md +++ b/README.md @@ -35,14 +35,24 @@ starten (beim ersten Mal ggf. Rechtsklick → Öffnen wegen Gatekeeper). ## Ablauf -1. **Tutorialbild** (nur beim Programmstart) – weiter mit A / Space +1. **Tutorialbild** (jede Runde, auch nach einem Ergebnis) – 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 +4. **Ergebnis**: Stufe, Prozent und ein Spruch – dazu **10 Sekunden Cooldown**, + damit man nicht versehentlich weiterspringt. Danach startet A eine neue + Runde (wieder ab Punkt 1). Beendet wird ausschliesslich mit **Esc** (Controller: B). +Beim Start jeder Runde steht im Terminal, welche Sequenz gezogen wurde, z. B.: + +``` +Runde 1: Sequenz 4/6 (16 Koffer, Start: 3x2, 3x1, 2x2, 2x1, 4x2 …), Stufe Normal +Runde 2: Sequenz 3/6 (19 Koffer, Start: 4x2, 4x1, 3x1, 2x1, 1x1 …), Stufe Normal +``` + +Dieselbe Sequenz kommt nie zweimal direkt hintereinander. + ## Steuerung | Aktion | Tastatur | Controller (Xbox-Layout) | @@ -71,12 +81,13 @@ loader_game/ sequences.py Einlesen und Validieren der Sequenzdatei case.py Koffer: Geometrie, Textur, Bewegung trailer.py Stapelregeln im Auflieger (reine Logik) + sayings.py Sprüche zum Ergebnis (nach Prozent gestuft) input_map.py Tastatur/Controller → Action scene.py Zeichnen menu.py Stufenauswahl intro.py Tutorialbild, Ampel, Reissverschluss game.py Zustandsmaschine einer Runde -tests/test_logic.py 48 Tests (ohne Fenster) +tests/test_logic.py 56 Tests (ohne Fenster) docs/ANALYSE.md Analyse Altstand + Fehlerliste old_version/ eingefrorener Altstand (nicht versioniert) ``` diff --git a/docs/ANALYSE.md b/docs/ANALYSE.md index 7ba7cbb..3a56cd0 100644 --- a/docs/ANALYSE.md +++ b/docs/ANALYSE.md @@ -94,3 +94,14 @@ Rückmeldungen aus dem ersten Durchlauf auf dem MacBook und was daraus wurde: | „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. | + +## 7. Änderungen nach dem zweiten Test (v1.2) + +| Rückmeldung | Umsetzung | +| --- | --- | +| „zeig im Log, welche Map gewählt wurde" | Beim Start jeder Runde: `Runde n: Sequenz x/6 (m Koffer, Start: …), Stufe …`. Zusätzlich wird dieselbe Sequenz nie zweimal direkt hintereinander gezogen (`Game._pick_sequence`). | +| „Reihenfolge: Anleitung → Moduswahl → Sequenz" | Phasenkette korrigiert. Die Anleitung kommt jetzt **jede** Runde zuerst, danach die Stufenauswahl, dann Ampel/Reissverschluss, dann das Spiel. | +| „kein Roll-Sound bis zum ersten Klick" | Ursache: der Roll-Sound lief auf einem Kanal, den die automatische Kanalvergabe für Einzelklänge (Ampel, Reissverschluss) belegen konnte; war er belegt, wurde der Roll übersprungen. Jetzt: `pygame.mixer.set_reserved(2)`, eigener Kanal 0 für den Roll, `unpause()` nach dem Laden und kein „skip when busy" mehr. Lautstärke 0.3 → 0.5. Beim Start steht `Audio: …` im Log. | +| „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. | diff --git a/loader_game/assets.py b/loader_game/assets.py index 1755105..c61574b 100644 --- a/loader_game/assets.py +++ b/loader_game/assets.py @@ -87,14 +87,18 @@ class Assets: except pygame.error: continue if "roll" in self.sounds: - self.sounds["roll"].set_volume(0.3) + self.sounds["roll"].set_volume(0.5) for name in ("place", "fail"): if name in self.sounds: self.sounds[name].set_volume(0.6) if "zip" in self.sounds: self.sounds["zip"].set_volume(1.0) pygame.mixer.set_num_channels(max(8, pygame.mixer.get_num_channels())) - self._roll_channel = pygame.mixer.Channel(1) + # Kanal 0 bleibt für den Roll-Sound reserviert, damit ihn kein + # automatisch vergebener Einzelklang wegschnappt. + pygame.mixer.set_reserved(2) + self._roll_channel = pygame.mixer.Channel(0) + pygame.mixer.unpause() def _load_beeps(self) -> None: if not pygame.mixer.get_init(): @@ -144,8 +148,9 @@ class Assets: if sound is None: return if name == "roll" and self._roll_channel is not None: - if not self._roll_channel.get_busy(): - self._roll_channel.play(sound, loops=loops) + # Immer neu starten: ein evtl. noch laufender Roll (z. B. aus der + # vorherigen Runde) darf den neuen nicht blockieren. + self._roll_channel.play(sound, loops=loops) return sound.play(loops=loops) @@ -157,6 +162,14 @@ class Assets: if pygame.mixer.get_init(): pygame.mixer.stop() + def audio_info(self) -> str: + """Kurzauskunft fürs Start-Log.""" + init = pygame.mixer.get_init() + if not init: + return "Audio: nicht verfügbar (kein Ausgabegerät?)" + rate, _, channels = init + return f"Audio: {rate} Hz, {channels} Kanäle, {len(self.sounds)} Sounds geladen" + # ------------------------------------------------------------------ # Töne ohne Dateien (für das Intro) # ------------------------------------------------------------------ diff --git a/loader_game/case.py b/loader_game/case.py index 8b04ca1..e66f378 100644 --- a/loader_game/case.py +++ b/loader_game/case.py @@ -77,7 +77,7 @@ class Case: else difficulty.speed_slow ) self.x -= speed * dt - if self.x <= wall_x + config.SNAP_THRESHOLD: + if self.x <= wall_x + config.SNAP_WINDOW: self.allow_snap = True def rotate_footprint(self) -> None: diff --git a/loader_game/config.py b/loader_game/config.py index 339b1c9..dba2e7d 100644 --- a/loader_game/config.py +++ b/loader_game/config.py @@ -68,8 +68,12 @@ MAX_UNITS_H = 3 # -------------------------------------------------------------------------- # Spieltempo (Pixel pro Sekunde; Stufen siehe difficulty.py) # -------------------------------------------------------------------------- -FAST_TO_SLOW_DISTANCE = 120 # ab hier wird abgebremst -SNAP_THRESHOLD = 60 # ab hier darf eingerastet werden +# Ab dieser Entfernung zur Barriere wird abgebremst – früh, damit man Zeit hat. +FAST_TO_SLOW_DISTANCE = 260 +# Ab dieser Entfernung darf eingerastet/gedreht/gestapelt werden. +SNAP_WINDOW = 160 +# Lücke, die nach einer Kollision (ohne Einrasten) entsteht – Strafe bleibt wie bisher. +FAIL_GAP = 60 PLATFORM_TOLERANCE = 5 # Toleranz für "bündig an der Barriere" # Animationsdauern in Sekunden (vorher: 10 Frames à 16,6 ms) @@ -150,3 +154,5 @@ MENU_TOP = 470 # -------------------------------------------------------------------------- RESULT_TEMPLATE = "Stufe {difficulty}: {percent}% Beladung" RESULT_HINT = "Nochmals? A / SPACE = neue Runde ESC = beenden" +RESULT_COOLDOWN_TEMPLATE = "Neue Runde in {seconds} s … (ESC = beenden)" +RESULT_COOLDOWN = 10.0 # Sekunden, bevor "neue Runde" angenommen wird diff --git a/loader_game/game.py b/loader_game/game.py index 12adc0c..66866e2 100644 --- a/loader_game/game.py +++ b/loader_game/game.py @@ -3,6 +3,7 @@ from __future__ import annotations import enum +import math import random from dataclasses import dataclass @@ -10,6 +11,7 @@ from . import config, scene from .case import Case from .difficulty import DEFAULT_DIFFICULTY, Difficulty from .input_map import Action +from .sayings import saying_for from .sequences import CaseSpec, FALLBACK_SEQUENCE from .trailer import Trailer @@ -23,7 +25,7 @@ class HandleResult(enum.Enum): """Was der Aufrufer nach einer Aktion tun soll.""" CONTINUE = "continue" - NEW_ROUND = "new_round" # zurück zur Stufenauswahl + NEW_ROUND = "new_round" # zurück zur Anleitung/Stufenauswahl QUIT = "quit" # Programm beenden (Esc / B) @@ -55,6 +57,7 @@ class Game: *, difficulty: Difficulty = DEFAULT_DIFFICULTY, rng: random.Random | None = None, + previous_sequence: int | None = None, ) -> None: self.assets = assets self.rng = rng or random.Random() @@ -62,20 +65,33 @@ class Game: self.state = State.PLAYING self.trailer = Trailer() + source = sequences if sequences else [list(FALLBACK_SEQUENCE)] - self.sequence: list[CaseSpec] = list(self.rng.choice(source)) + self.sequence_index = self._pick_sequence(len(source), previous_sequence) + self.sequence: list[CaseSpec] = list(source[self.sequence_index]) self.index = 0 self.current: Case | None = None self.animation: Animation | None = None self.shake = 0.0 self.percent = 0.0 + self.result_timer = 0.0 + self.result_saying = "" self._spawn() # ------------------------------------------------------------------ # Sequenz / Nachschub # ------------------------------------------------------------------ + def _pick_sequence(self, count: int, previous: int | None) -> int: + """Zufällige Sequenz – aber nie zweimal direkt hintereinander dieselbe.""" + if count <= 1: + return 0 + if previous is None or not 0 <= previous < count: + return self.rng.randrange(count) + choice = self.rng.randrange(count - 1) + return choice if choice < previous else choice + 1 + def has_next(self) -> bool: return self.index < len(self.sequence) @@ -107,7 +123,9 @@ class Game: return HandleResult.QUIT if self.state is State.FINISHED: - if action is Action.PRIMARY: + # Erst nach dem Cooldown darf die nächste Runde starten – sonst + # springt man beim Weggehen versehentlich weiter. + if action is Action.PRIMARY and self.result_timer <= 0: return HandleResult.NEW_ROUND return HandleResult.CONTINUE @@ -128,9 +146,10 @@ class Game: def _start_snap(self, case: Case) -> None: self._stop_roll() + distance = max(1.0, case.x - self.trailer.wall_x) self.animation = Animation( kind="snap", - duration=config.ANIM_SNAP, + duration=min(0.30, max(0.08, distance / 1200.0)), start_x=case.x, start_y=case.y, target_x=self.trailer.wall_x, @@ -172,7 +191,9 @@ class Game: def update(self, dt: float) -> None: if self.shake > 0: self.shake = max(0.0, self.shake - dt) - if self.state is not State.PLAYING: + + if self.state is State.FINISHED: + self.result_timer = max(0.0, self.result_timer - dt) return if self.animation is not None: @@ -223,7 +244,7 @@ class Game: if animation.kind == "rotate": case.rotate_footprint() case.y = float(config.TRAILER_BOTTOM - case.height) - case.x = max(self.trailer.wall_x + config.SNAP_THRESHOLD // 2, case.x) + case.x = max(self.trailer.wall_x + config.SNAP_WINDOW // 2, case.x) case.can_rotate = False return @@ -270,6 +291,8 @@ class Game: self.percent = self.trailer.load_percent() if self.percent >= 99.9: self.percent = 100.0 + self.result_timer = config.RESULT_COOLDOWN + self.result_saying = saying_for(self.percent, self.rng) # ------------------------------------------------------------------ # Zeichnen @@ -313,12 +336,22 @@ class Game: scene.draw_overlay_lines(surface, assets, self.result_lines()) def result_lines(self) -> list[str]: - return [ + """Ergebniszeilen: Stufe+Prozent, Spruch, dann Cooldown bzw. Hinweis.""" + lines = [ config.RESULT_TEMPLATE.format( difficulty=self.difficulty.label, percent=int(self.percent) ), - config.RESULT_HINT, + self.result_saying, ] + if self.result_timer > 0: + lines.append( + config.RESULT_COOLDOWN_TEMPLATE.format( + seconds=math.ceil(self.result_timer) + ) + ) + else: + lines.append(config.RESULT_HINT) + return lines # ------------------------------------------------------------------ # Hilfen diff --git a/loader_game/sayings.py b/loader_game/sayings.py new file mode 100644 index 0000000..fd7d4b4 --- /dev/null +++ b/loader_game/sayings.py @@ -0,0 +1,68 @@ +"""Lustige Sprüche zum Ergebnis. + +Zielgruppe sind Jugendliche am Zukunftstag: lieber augenzwinkernd als fies. +Wer nichts tut, landet bei etwa 40-60 % – deshalb sind die Sprüche ab 70 % +lobend und darunter nur leicht frech. +""" + +from __future__ import annotations + +import random + +#: Bei 100 % – der Wunsch aus dem Feedback. +CONTRACT_LINE = "100%! Drücke A und unterschreib gleich einen Vertrag im Lager!" + +TOP_LINES = ( + "Das war Lade-Profi-Niveau. Der Staplerfahrer will ein Autogramm.", + "So voll sieht dieser Auflieger selten aus – Respekt!", + "Da passt nicht mal mehr eine Müsliriegel-Packung rein. Stark!", +) + +GREAT_LINES = ( + "Sehr sauber geladen – damit würdest du im Lager sofort auffallen.", + "Das ist Tetris mit Koffern. Weiter so!", + "Fast Profi. Nur die letzte Lücke hat noch etwas Luft.", +) + +GOOD_LINES = ( + "Solide! Der Auflieger ist zufrieden, die Koffer auch.", + "Passt schon – ein bisschen Luft ist noch drin, aber das ist okay.", + "Ordentlich beladen. Kein Rekord, aber auch kein Chaos.", +) + +MEH_LINES = ( + "Naja … die Koffer sind drin. Irgendwie.", + "Da geht noch was – der Auflieger hat Platz für einen Koffer mehr.", + "Halb voll ist auch halb leer. Nächstes Mal!", +) + +LOW_LINES = ( + "Ui, da ist noch viel Luft drin. Die Koffer haben sich verlaufen.", + "Der Auflieger wartet noch auf Nachschub. Weiter probieren!", + "Eher Tetris im Anfänger-Modus – aber du bist dabei, das zählt.", +) + +OUCH_LINES = ( + "Der Auflieger ist noch fast leer – aber der Anfang ist gemacht!", + "Autsch. Weisst du was? Nochmal ist immer eine Option.", + "Fast leer. Dafür kann es jetzt nur noch aufwärts gehen.", +) + + +def saying_for(percent: float, rng: random.Random | None = None) -> str: + """Passender Spruch zur erreichten Beladung.""" + if percent >= 100: + return CONTRACT_LINE + if percent >= 85: + pool = TOP_LINES + elif percent >= 70: + pool = GREAT_LINES + elif percent >= 55: + pool = GOOD_LINES + elif percent >= 40: + pool = MEH_LINES + elif percent >= 20: + pool = LOW_LINES + else: + pool = OUCH_LINES + return (rng or random).choice(pool) diff --git a/loader_game/scene.py b/loader_game/scene.py index 48faeec..a63fd44 100644 --- a/loader_game/scene.py +++ b/loader_game/scene.py @@ -205,23 +205,62 @@ def draw_dim(surface: pygame.Surface, alpha: int = 170) -> None: surface.blit(overlay, (0, 0)) +def _wrap_text(text: str, font: pygame.font.Font, max_width: int) -> list[str]: + """Bricht eine Zeile an Wortgrenzen um, wenn sie zu breit würde.""" + words = text.split() + if not words: + return [text] + lines: list[str] = [] + current = words[0] + for word in words[1:]: + candidate = f"{current} {word}" + if font.size(candidate)[0] <= max_width: + current = candidate + else: + lines.append(current) + current = word + lines.append(current) + return lines + + def draw_overlay_lines( surface: pygame.Surface, assets, lines: list[str], ) -> None: - """Dunkler Schleier mit zentrierten Textzeilen (Ergebnis-Anzeige).""" + """Dunkler Schleier mit zentrierten Textzeilen (Ergebnis-Anzeige). + + Die erste Zeile wird gross gesetzt, alle weiteren klein – so passen auch + längere Sprüche auf den Schirm. + """ draw_dim(surface, 220) - center_y = config.SCREEN_HEIGHT // 2 - line_gap = 16 + big = assets.font + small = assets.font_small or big + max_width = config.SCREEN_WIDTH - 200 + rendered: list[pygame.Surface] = [] - total_height = 0 - for line in lines: - image = assets.font.render(line, True, config.COLOR_WHITE) - rendered.append(image) - total_height += image.get_height() + line_gap - y = center_y - total_height // 2 + for index, line in enumerate(lines): + font = big if index == 0 else small + for part in _wrap_text(line, font, max_width): + rendered.append(font.render(part, True, config.COLOR_WHITE)) + + line_gap = 18 + total_height = sum(image.get_height() + line_gap for image in rendered) + y = config.SCREEN_HEIGHT // 2 - total_height // 2 + + # Dunkles Band hinter dem Text, damit er auch über Koffern lesbar bleibt. + widest = max(image.get_width() for image in rendered) + band = pygame.Surface((widest + 120, total_height + 20), pygame.SRCALPHA) + band.fill((0, 0, 0, 170)) + surface.blit( + band, + ( + config.SCREEN_WIDTH // 2 - band.get_width() // 2, + y - 10, + ), + ) + for image in rendered: surface.blit(image, (config.SCREEN_WIDTH // 2 - image.get_width() // 2, y)) y += image.get_height() + line_gap diff --git a/loader_game/trailer.py b/loader_game/trailer.py index 1f106d9..a1c28fe 100644 --- a/loader_game/trailer.py +++ b/loader_game/trailer.py @@ -104,7 +104,7 @@ class Trailer: def stack_with_gap(self, case: Case) -> None: """Koffer mit einer Lücke einreihen (nach einer Kollision).""" - case.x = self.wall_x + config.SNAP_THRESHOLD + case.x = self.wall_x + config.FAIL_GAP case.placed = True self.stack.append(case) self.wall_x = case.right diff --git a/main.py b/main.py index 433a70c..6058f5b 100755 --- a/main.py +++ b/main.py @@ -101,12 +101,16 @@ def main(argv: list[str] | None = None) -> int: for line in controller_hint_lines(): print(line) + print(assets.audio_info()) + sequences, warnings = load_sequences() for warning in warnings: print("Sequenz-Warnung:", warning, file=sys.stderr) rng = random.Random(args.seed) difficulty: Difficulty = DEFAULT_DIFFICULTY + round_number = 0 + previous_sequence: int | None = None phase = Phase.INTRO after_intro = Phase.MENU # nach dem Tutorial kommt die Stufenauswahl @@ -142,19 +146,36 @@ def main(argv: list[str] | None = None) -> int: if result is HandleResult.QUIT: return False if result is HandleResult.NEW_ROUND: - menu = DifficultyMenu(assets, selected=difficulty) - phase = Phase.MENU + # Wie beim Programmstart: Anleitung -> Stufenauswahl -> Ampel + intro = Intro(assets, show_instruction=True) + after_intro = Phase.MENU + menu = None + phase = Phase.INTRO return True def advance(dt: float) -> None: """Weiterrechnen und Phasenwechsel ohne Eingabe.""" - nonlocal phase, intro, menu, game + nonlocal phase, intro, menu, 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: - game = Game(assets, sequences, difficulty=difficulty, rng=rng) + 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 diff --git a/tests/test_logic.py b/tests/test_logic.py index 90a6488..cd8d983 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -20,6 +20,7 @@ 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.sayings import saying_for from loader_game.sequences import CaseSpec, load_sequences, parse_sequences from loader_game.trailer import Trailer @@ -131,8 +132,8 @@ class CaseTests(unittest.TestCase): case.move(1 / 60, wall_x=0) self.assertFalse(case.allow_snap) - case = self.make_case(x=config.SNAP_THRESHOLD + 2) - case.move(1 / 60, wall_x=0) + case = self.make_case(x=config.SNAP_WINDOW + 2) + case.move(1 / 60, wall_x=0, difficulty=DIFFICULTIES[0]) self.assertTrue(case.allow_snap) def test_placed_case_does_not_move(self) -> None: @@ -207,7 +208,7 @@ class TrailerTests(unittest.TestCase): def test_gap_placement_leaves_hole(self) -> None: case = self.make_case(2, 1) self.trailer.stack_with_gap(case) - self.assertEqual(case.x, self.trailer.x + config.SNAP_THRESHOLD) + self.assertEqual(case.x, self.trailer.x + config.FAIL_GAP) self.assertFalse(case.snapped) def test_platform_on_flat_ground_case_is_allowed(self) -> None: @@ -321,7 +322,7 @@ class GameTests(unittest.TestCase): rng=random.Random(1), ) case = game.current - case.x = float(game.trailer.wall_x + config.SNAP_THRESHOLD + 1) + case.x = float(game.trailer.wall_x + config.SNAP_WINDOW + 1) case.allow_snap = True game.handle(Action.LEFT) self.update_until(game, lambda: game.animation is None) @@ -393,6 +394,10 @@ class GameTests(unittest.TestCase): game.handle(Action.PRIMARY) game.update(1 / 60) self.assertIs(game.state, State.FINISHED) + # Cooldown: erst nach Ablauf darf die nächste Runde starten + self.assertIs(game.handle(Action.PRIMARY), HandleResult.CONTINUE) + for _ in range(int(config.RESULT_COOLDOWN * 60) + 5): + game.update(1 / 60) self.assertIs(game.handle(Action.PRIMARY), HandleResult.NEW_ROUND) def test_result_lines_show_difficulty_and_percent(self) -> None: @@ -404,6 +409,81 @@ class GameTests(unittest.TestCase): self.assertTrue(any("ESC" in line for line in lines)) + +class SayingTests(unittest.TestCase): + def test_full_load_gets_the_contract_line(self) -> None: + self.assertIn("Vertrag", saying_for(100)) + self.assertIn("Vertrag", saying_for(100.0)) + + def test_every_percent_gets_a_saying(self) -> None: + rng = random.Random(5) + for percent in range(0, 101, 5): + self.assertTrue(saying_for(percent, rng).strip()) + + def test_high_and_low_use_different_pools(self) -> None: + rng = random.Random(1) + high = {saying_for(90, rng) for _ in range(40)} + low = {saying_for(10, rng) for _ in range(40)} + self.assertFalse(high & low) + + def test_no_harsh_words(self) -> None: + # Zielgruppe Jugendliche: frech ja, beleidigend nein. + bad = ("dumm", "idiot", "blöd", "bloed", "versager", "looser", "hässlich") + rng = random.Random(4) + for percent in range(0, 101, 5): + text = saying_for(percent, rng).lower() + for word in bad: + self.assertNotIn(word, text) + + +class ResultCooldownTests(unittest.TestCase): + def finish_a_round(self) -> Game: + game = Game( + None, + [[CaseSpec(1, 1)]], + difficulty=DIFFICULTIES[1], + rng=random.Random(2), + ) + for _ in range(4000): + if game.state is State.FINISHED: + break + game.update(1 / 60) + self.assertIs(game.state, State.FINISHED) + return game + + def test_countdown_then_hint(self) -> None: + game = self.finish_a_round() + lines = game.result_lines() + self.assertEqual(len(lines), 3) + self.assertIn("Neue Runde in", lines[2]) + self.assertIn("%", lines[0]) + + for _ in range(int(config.RESULT_COOLDOWN * 60) + 5): + game.update(1 / 60) + self.assertIn("ESC", game.result_lines()[2]) + + def test_primary_is_ignored_during_cooldown(self) -> None: + game = self.finish_a_round() + self.assertIs(game.handle(Action.PRIMARY), HandleResult.CONTINUE) + game.update(config.RESULT_COOLDOWN / 2) + self.assertIs(game.handle(Action.PRIMARY), HandleResult.CONTINUE) + + +class SequenceChoiceTests(unittest.TestCase): + def test_never_repeats_the_previous_sequence(self) -> None: + sequences = [[CaseSpec(1, 1)] for _ in range(4)] + rng = random.Random(11) + previous = None + for _ in range(40): + game = Game(None, sequences, rng=rng, previous_sequence=previous) + self.assertNotEqual(game.sequence_index, previous) + previous = game.sequence_index + + def test_single_sequence_is_allowed(self) -> None: + game = Game(None, [[CaseSpec(1, 1)]], rng=random.Random(1), previous_sequence=0) + self.assertEqual(game.sequence_index, 0) + + class RenderTests(unittest.TestCase): """Rendert je ein Bild von Intro, Menü und Spiel (ohne Fenster).