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.
This commit is contained in:
1 parent
d970185e75
commit
e3a0c5a57d
11 files changed
+318
-36
No files matched your search
@@ -35,14 +35,24 @@ starten (beim ersten Mal ggf. Rechtsklick → Öffnen wegen Gatekeeper).
|
|||||||
|
|
||||||
## Ablauf
|
## 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)
|
2. **Stufenauswahl**: Easy / Normal / Hard (Hard = Tempo des Altstands)
|
||||||
3. **Ampel-Start** (rot/gelb/grün + Reissverschluss), dann läuft die Runde
|
3. **Ampel-Start** (rot/gelb/grün + Reissverschluss), dann läuft die Runde
|
||||||
4. **Ergebnis**: Stufe und Beladung in Prozent, „Nochmals?" → A startet neu
|
4. **Ergebnis**: Stufe, Prozent und ein Spruch – dazu **10 Sekunden Cooldown**,
|
||||||
bei der Stufenauswahl
|
damit man nicht versehentlich weiterspringt. Danach startet A eine neue
|
||||||
|
Runde (wieder ab Punkt 1).
|
||||||
|
|
||||||
Beendet wird ausschliesslich mit **Esc** (Controller: B).
|
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
|
## Steuerung
|
||||||
|
|
||||||
| Aktion | Tastatur | Controller (Xbox-Layout) |
|
| Aktion | Tastatur | Controller (Xbox-Layout) |
|
||||||
@@ -71,12 +81,13 @@ loader_game/
|
|||||||
sequences.py Einlesen und Validieren der Sequenzdatei
|
sequences.py Einlesen und Validieren der Sequenzdatei
|
||||||
case.py Koffer: Geometrie, Textur, Bewegung
|
case.py Koffer: Geometrie, Textur, Bewegung
|
||||||
trailer.py Stapelregeln im Auflieger (reine Logik)
|
trailer.py Stapelregeln im Auflieger (reine Logik)
|
||||||
|
sayings.py Sprüche zum Ergebnis (nach Prozent gestuft)
|
||||||
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 Tutorialbild, Ampel, Reissverschluss
|
||||||
game.py Zustandsmaschine einer Runde
|
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
|
docs/ANALYSE.md Analyse Altstand + Fehlerliste
|
||||||
old_version/ eingefrorener Altstand (nicht versioniert)
|
old_version/ eingefrorener Altstand (nicht versioniert)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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. |
|
| „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`. |
|
| „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. |
|
| „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. |
|
||||||
+16
-3
@@ -87,14 +87,18 @@ class Assets:
|
|||||||
except pygame.error:
|
except pygame.error:
|
||||||
continue
|
continue
|
||||||
if "roll" in self.sounds:
|
if "roll" in self.sounds:
|
||||||
self.sounds["roll"].set_volume(0.3)
|
self.sounds["roll"].set_volume(0.5)
|
||||||
for name in ("place", "fail"):
|
for name in ("place", "fail"):
|
||||||
if name in self.sounds:
|
if name in self.sounds:
|
||||||
self.sounds[name].set_volume(0.6)
|
self.sounds[name].set_volume(0.6)
|
||||||
if "zip" in self.sounds:
|
if "zip" in self.sounds:
|
||||||
self.sounds["zip"].set_volume(1.0)
|
self.sounds["zip"].set_volume(1.0)
|
||||||
pygame.mixer.set_num_channels(max(8, pygame.mixer.get_num_channels()))
|
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:
|
def _load_beeps(self) -> None:
|
||||||
if not pygame.mixer.get_init():
|
if not pygame.mixer.get_init():
|
||||||
@@ -144,7 +148,8 @@ class Assets:
|
|||||||
if sound is None:
|
if sound is None:
|
||||||
return
|
return
|
||||||
if name == "roll" and self._roll_channel is not None:
|
if name == "roll" and self._roll_channel is not None:
|
||||||
if not self._roll_channel.get_busy():
|
# 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)
|
self._roll_channel.play(sound, loops=loops)
|
||||||
return
|
return
|
||||||
sound.play(loops=loops)
|
sound.play(loops=loops)
|
||||||
@@ -157,6 +162,14 @@ class Assets:
|
|||||||
if pygame.mixer.get_init():
|
if pygame.mixer.get_init():
|
||||||
pygame.mixer.stop()
|
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)
|
# Töne ohne Dateien (für das Intro)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ class Case:
|
|||||||
else difficulty.speed_slow
|
else difficulty.speed_slow
|
||||||
)
|
)
|
||||||
self.x -= speed * dt
|
self.x -= speed * dt
|
||||||
if self.x <= wall_x + config.SNAP_THRESHOLD:
|
if self.x <= wall_x + config.SNAP_WINDOW:
|
||||||
self.allow_snap = True
|
self.allow_snap = True
|
||||||
|
|
||||||
def rotate_footprint(self) -> None:
|
def rotate_footprint(self) -> None:
|
||||||
|
|||||||
@@ -68,8 +68,12 @@ MAX_UNITS_H = 3
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Spieltempo (Pixel pro Sekunde; Stufen siehe difficulty.py)
|
# Spieltempo (Pixel pro Sekunde; Stufen siehe difficulty.py)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
FAST_TO_SLOW_DISTANCE = 120 # ab hier wird abgebremst
|
# Ab dieser Entfernung zur Barriere wird abgebremst – früh, damit man Zeit hat.
|
||||||
SNAP_THRESHOLD = 60 # ab hier darf eingerastet werden
|
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"
|
PLATFORM_TOLERANCE = 5 # Toleranz für "bündig an der Barriere"
|
||||||
|
|
||||||
# Animationsdauern in Sekunden (vorher: 10 Frames à 16,6 ms)
|
# Animationsdauern in Sekunden (vorher: 10 Frames à 16,6 ms)
|
||||||
@@ -150,3 +154,5 @@ MENU_TOP = 470
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
RESULT_TEMPLATE = "Stufe {difficulty}: {percent}% Beladung"
|
RESULT_TEMPLATE = "Stufe {difficulty}: {percent}% Beladung"
|
||||||
RESULT_HINT = "Nochmals? A / SPACE = neue Runde ESC = beenden"
|
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
|
||||||
+41
-8
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import enum
|
import enum
|
||||||
|
import math
|
||||||
import random
|
import random
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ from . import config, scene
|
|||||||
from .case import Case
|
from .case import Case
|
||||||
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
|
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
|
||||||
from .input_map import Action
|
from .input_map import Action
|
||||||
|
from .sayings import saying_for
|
||||||
from .sequences import CaseSpec, FALLBACK_SEQUENCE
|
from .sequences import CaseSpec, FALLBACK_SEQUENCE
|
||||||
from .trailer import Trailer
|
from .trailer import Trailer
|
||||||
|
|
||||||
@@ -23,7 +25,7 @@ class HandleResult(enum.Enum):
|
|||||||
"""Was der Aufrufer nach einer Aktion tun soll."""
|
"""Was der Aufrufer nach einer Aktion tun soll."""
|
||||||
|
|
||||||
CONTINUE = "continue"
|
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)
|
QUIT = "quit" # Programm beenden (Esc / B)
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +57,7 @@ class Game:
|
|||||||
*,
|
*,
|
||||||
difficulty: Difficulty = DEFAULT_DIFFICULTY,
|
difficulty: Difficulty = DEFAULT_DIFFICULTY,
|
||||||
rng: random.Random | None = None,
|
rng: random.Random | None = None,
|
||||||
|
previous_sequence: int | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.assets = assets
|
self.assets = assets
|
||||||
self.rng = rng or random.Random()
|
self.rng = rng or random.Random()
|
||||||
@@ -62,20 +65,33 @@ class Game:
|
|||||||
|
|
||||||
self.state = State.PLAYING
|
self.state = State.PLAYING
|
||||||
self.trailer = Trailer()
|
self.trailer = Trailer()
|
||||||
|
|
||||||
source = sequences if sequences else [list(FALLBACK_SEQUENCE)]
|
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.index = 0
|
||||||
self.current: Case | None = None
|
self.current: Case | None = None
|
||||||
self.animation: Animation | None = None
|
self.animation: Animation | None = None
|
||||||
self.shake = 0.0
|
self.shake = 0.0
|
||||||
self.percent = 0.0
|
self.percent = 0.0
|
||||||
|
self.result_timer = 0.0
|
||||||
|
self.result_saying = ""
|
||||||
|
|
||||||
self._spawn()
|
self._spawn()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Sequenz / Nachschub
|
# 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:
|
def has_next(self) -> bool:
|
||||||
return self.index < len(self.sequence)
|
return self.index < len(self.sequence)
|
||||||
|
|
||||||
@@ -107,7 +123,9 @@ class Game:
|
|||||||
return HandleResult.QUIT
|
return HandleResult.QUIT
|
||||||
|
|
||||||
if self.state is State.FINISHED:
|
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.NEW_ROUND
|
||||||
return HandleResult.CONTINUE
|
return HandleResult.CONTINUE
|
||||||
|
|
||||||
@@ -128,9 +146,10 @@ class Game:
|
|||||||
|
|
||||||
def _start_snap(self, case: Case) -> None:
|
def _start_snap(self, case: Case) -> None:
|
||||||
self._stop_roll()
|
self._stop_roll()
|
||||||
|
distance = max(1.0, case.x - self.trailer.wall_x)
|
||||||
self.animation = Animation(
|
self.animation = Animation(
|
||||||
kind="snap",
|
kind="snap",
|
||||||
duration=config.ANIM_SNAP,
|
duration=min(0.30, max(0.08, distance / 1200.0)),
|
||||||
start_x=case.x,
|
start_x=case.x,
|
||||||
start_y=case.y,
|
start_y=case.y,
|
||||||
target_x=self.trailer.wall_x,
|
target_x=self.trailer.wall_x,
|
||||||
@@ -172,7 +191,9 @@ class Game:
|
|||||||
def update(self, dt: float) -> None:
|
def update(self, dt: float) -> None:
|
||||||
if self.shake > 0:
|
if self.shake > 0:
|
||||||
self.shake = max(0.0, self.shake - dt)
|
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
|
return
|
||||||
|
|
||||||
if self.animation is not None:
|
if self.animation is not None:
|
||||||
@@ -223,7 +244,7 @@ class Game:
|
|||||||
if animation.kind == "rotate":
|
if animation.kind == "rotate":
|
||||||
case.rotate_footprint()
|
case.rotate_footprint()
|
||||||
case.y = float(config.TRAILER_BOTTOM - case.height)
|
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
|
case.can_rotate = False
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -270,6 +291,8 @@ class Game:
|
|||||||
self.percent = self.trailer.load_percent()
|
self.percent = self.trailer.load_percent()
|
||||||
if self.percent >= 99.9:
|
if self.percent >= 99.9:
|
||||||
self.percent = 100.0
|
self.percent = 100.0
|
||||||
|
self.result_timer = config.RESULT_COOLDOWN
|
||||||
|
self.result_saying = saying_for(self.percent, self.rng)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Zeichnen
|
# Zeichnen
|
||||||
@@ -313,12 +336,22 @@ class Game:
|
|||||||
scene.draw_overlay_lines(surface, assets, self.result_lines())
|
scene.draw_overlay_lines(surface, assets, self.result_lines())
|
||||||
|
|
||||||
def result_lines(self) -> list[str]:
|
def result_lines(self) -> list[str]:
|
||||||
return [
|
"""Ergebniszeilen: Stufe+Prozent, Spruch, dann Cooldown bzw. Hinweis."""
|
||||||
|
lines = [
|
||||||
config.RESULT_TEMPLATE.format(
|
config.RESULT_TEMPLATE.format(
|
||||||
difficulty=self.difficulty.label, percent=int(self.percent)
|
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
|
# Hilfen
|
||||||
|
|||||||
@@ -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)
|
||||||
+48
-9
@@ -205,23 +205,62 @@ def draw_dim(surface: pygame.Surface, alpha: int = 170) -> None:
|
|||||||
surface.blit(overlay, (0, 0))
|
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(
|
def draw_overlay_lines(
|
||||||
surface: pygame.Surface,
|
surface: pygame.Surface,
|
||||||
assets,
|
assets,
|
||||||
lines: list[str],
|
lines: list[str],
|
||||||
) -> None:
|
) -> 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)
|
draw_dim(surface, 220)
|
||||||
|
|
||||||
center_y = config.SCREEN_HEIGHT // 2
|
big = assets.font
|
||||||
line_gap = 16
|
small = assets.font_small or big
|
||||||
|
max_width = config.SCREEN_WIDTH - 200
|
||||||
|
|
||||||
rendered: list[pygame.Surface] = []
|
rendered: list[pygame.Surface] = []
|
||||||
total_height = 0
|
for index, line in enumerate(lines):
|
||||||
for line in lines:
|
font = big if index == 0 else small
|
||||||
image = assets.font.render(line, True, config.COLOR_WHITE)
|
for part in _wrap_text(line, font, max_width):
|
||||||
rendered.append(image)
|
rendered.append(font.render(part, True, config.COLOR_WHITE))
|
||||||
total_height += image.get_height() + line_gap
|
|
||||||
y = center_y - total_height // 2
|
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:
|
for image in rendered:
|
||||||
surface.blit(image, (config.SCREEN_WIDTH // 2 - image.get_width() // 2, y))
|
surface.blit(image, (config.SCREEN_WIDTH // 2 - image.get_width() // 2, y))
|
||||||
y += image.get_height() + line_gap
|
y += image.get_height() + line_gap
|
||||||
@@ -104,7 +104,7 @@ class Trailer:
|
|||||||
|
|
||||||
def stack_with_gap(self, case: Case) -> None:
|
def stack_with_gap(self, case: Case) -> None:
|
||||||
"""Koffer mit einer Lücke einreihen (nach einer Kollision)."""
|
"""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
|
case.placed = True
|
||||||
self.stack.append(case)
|
self.stack.append(case)
|
||||||
self.wall_x = case.right
|
self.wall_x = case.right
|
||||||
|
|||||||
@@ -101,12 +101,16 @@ 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())
|
||||||
|
|
||||||
sequences, warnings = load_sequences()
|
sequences, warnings = load_sequences()
|
||||||
for warning in warnings:
|
for warning in warnings:
|
||||||
print("Sequenz-Warnung:", warning, file=sys.stderr)
|
print("Sequenz-Warnung:", warning, file=sys.stderr)
|
||||||
|
|
||||||
rng = random.Random(args.seed)
|
rng = random.Random(args.seed)
|
||||||
difficulty: Difficulty = DEFAULT_DIFFICULTY
|
difficulty: Difficulty = DEFAULT_DIFFICULTY
|
||||||
|
round_number = 0
|
||||||
|
previous_sequence: int | None = None
|
||||||
|
|
||||||
phase = Phase.INTRO
|
phase = Phase.INTRO
|
||||||
after_intro = Phase.MENU # nach dem Tutorial kommt die Stufenauswahl
|
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:
|
if result is HandleResult.QUIT:
|
||||||
return False
|
return False
|
||||||
if result is HandleResult.NEW_ROUND:
|
if result is HandleResult.NEW_ROUND:
|
||||||
menu = DifficultyMenu(assets, selected=difficulty)
|
# Wie beim Programmstart: Anleitung -> Stufenauswahl -> Ampel
|
||||||
phase = Phase.MENU
|
intro = Intro(assets, show_instruction=True)
|
||||||
|
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
|
nonlocal phase, intro, menu, game, round_number, previous_sequence
|
||||||
|
|
||||||
if phase is Phase.INTRO and intro is not None:
|
if phase is Phase.INTRO and intro is not None:
|
||||||
if intro.update(dt):
|
if intro.update(dt):
|
||||||
intro = None
|
intro = None
|
||||||
if after_intro is Phase.PLAY:
|
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:
|
else:
|
||||||
menu = DifficultyMenu(assets, selected=difficulty)
|
menu = DifficultyMenu(assets, selected=difficulty)
|
||||||
phase = after_intro
|
phase = after_intro
|
||||||
|
|||||||
+84
-4
@@ -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.game import Game, HandleResult, State
|
||||||
from loader_game.input_map import Action
|
from loader_game.input_map import Action
|
||||||
from loader_game.menu import DifficultyMenu
|
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.sequences import CaseSpec, load_sequences, parse_sequences
|
||||||
from loader_game.trailer import Trailer
|
from loader_game.trailer import Trailer
|
||||||
|
|
||||||
@@ -131,8 +132,8 @@ class CaseTests(unittest.TestCase):
|
|||||||
case.move(1 / 60, wall_x=0)
|
case.move(1 / 60, wall_x=0)
|
||||||
self.assertFalse(case.allow_snap)
|
self.assertFalse(case.allow_snap)
|
||||||
|
|
||||||
case = self.make_case(x=config.SNAP_THRESHOLD + 2)
|
case = self.make_case(x=config.SNAP_WINDOW + 2)
|
||||||
case.move(1 / 60, wall_x=0)
|
case.move(1 / 60, wall_x=0, difficulty=DIFFICULTIES[0])
|
||||||
self.assertTrue(case.allow_snap)
|
self.assertTrue(case.allow_snap)
|
||||||
|
|
||||||
def test_placed_case_does_not_move(self) -> None:
|
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:
|
def test_gap_placement_leaves_hole(self) -> None:
|
||||||
case = self.make_case(2, 1)
|
case = self.make_case(2, 1)
|
||||||
self.trailer.stack_with_gap(case)
|
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)
|
self.assertFalse(case.snapped)
|
||||||
|
|
||||||
def test_platform_on_flat_ground_case_is_allowed(self) -> None:
|
def test_platform_on_flat_ground_case_is_allowed(self) -> None:
|
||||||
@@ -321,7 +322,7 @@ class GameTests(unittest.TestCase):
|
|||||||
rng=random.Random(1),
|
rng=random.Random(1),
|
||||||
)
|
)
|
||||||
case = game.current
|
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
|
case.allow_snap = True
|
||||||
game.handle(Action.LEFT)
|
game.handle(Action.LEFT)
|
||||||
self.update_until(game, lambda: game.animation is None)
|
self.update_until(game, lambda: game.animation is None)
|
||||||
@@ -393,6 +394,10 @@ class GameTests(unittest.TestCase):
|
|||||||
game.handle(Action.PRIMARY)
|
game.handle(Action.PRIMARY)
|
||||||
game.update(1 / 60)
|
game.update(1 / 60)
|
||||||
self.assertIs(game.state, State.FINISHED)
|
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)
|
self.assertIs(game.handle(Action.PRIMARY), HandleResult.NEW_ROUND)
|
||||||
|
|
||||||
def test_result_lines_show_difficulty_and_percent(self) -> None:
|
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))
|
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):
|
class RenderTests(unittest.TestCase):
|
||||||
"""Rendert je ein Bild von Intro, Menü und Spiel (ohne Fenster).
|
"""Rendert je ein Bild von Intro, Menü und Spiel (ohne Fenster).
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user