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
+319
-37
No files matched your search
+17
-4
@@ -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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
+41
-8
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user