Altstand nach old_version/ eingefroren (untracked). Monolith ersetzt durch main.py + Paket loader_game/. Neu: run.sh/run.command mit venv+requirements, .gitignore, docs/ANALYSE.md, 35 Tests. Secrets entfernt. Gelöscht: loader_game.py, qr_scan*.py, INSTALL_README.txt.

This commit is contained in:
Project Bridge committed 2026-11-01 10:25:13 +00:00
1 parent 6a2c891c3e
commit de550e1d4d
24 files changed
+2269 -1008

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
"""Habegger Loader Game – Spielpaket.
Aufbau (jedes Modul hat genau eine Aufgabe):
* :mod:`loader_game.config` – alle Masse, Farben, Tempi, Pfade
* :mod:`loader_game.assets` – Bilder, Sounds, Fonts laden und cachen
* :mod:`loader_game.sequences` – Einlesen von ``case_sequences.txt``
* :mod:`loader_game.case` – der Koffer (Geometrie und Bewegung)
* :mod:`loader_game.trailer` – Stapelregeln im Auflieger (reine Logik)
* :mod:`loader_game.input_map` – Tastatur/Controller → abstrakte Aktionen
* :mod:`loader_game.scene` – Zeichnen
* :mod:`loader_game.intro` – Tutorialbild + Ampel + Reissverschluss
* :mod:`loader_game.game` – Zustandsmaschine des Spiels
* :mod:`loader_game.storage` – Laufzeitdaten (letzter Spielername)
* :mod:`loader_game.net` – optionaler Punkte-Upload
"""
__version__ = "1.0.0"
+176
View File
@@ -0,0 +1,176 @@
"""Laden und Zwischenspeichern aller Bilder, Sounds und Fonts."""
from __future__ import annotations
import array
import math
import re
import pygame
from . import config
_CASE_FILENAME = re.compile(r"^case_w(\d+)_h(\d+)\.png$")
class Assets:
"""Sammelstelle für alles, was aus Dateien kommt.
Muss **nach** ``pygame.init()`` und ``pygame.display.set_mode()``
instanziiert werden, weil ``convert_alpha()`` ein Display voraussetzt.
Fehlende Dateien führen nicht zum Absturz, sondern zu ``None`` – das
Spiel zeichnet dann einen farbigen Platzhalter bzw. bleibt stumm.
"""
def __init__(self) -> None:
self.font: pygame.font.Font | None = None
self.font_small: pygame.font.Font | None = None
self.background: pygame.Surface | None = None
self.instruction: pygame.Surface | None = None
self.sounds: dict[str, pygame.mixer.Sound] = {}
self._case_originals: dict[tuple[int, int], pygame.Surface] = {}
self._case_cache: dict[tuple[int, int], pygame.Surface] = {}
self._roll_channel: pygame.mixer.Channel | None = None
# ------------------------------------------------------------------
# Laden
# ------------------------------------------------------------------
def load(self) -> None:
self.font = self._load_font(config.FONT_SIZE)
self.font_small = self._load_font(config.FONT_SIZE_SMALL)
self.background = self._load_image(config.BACKGROUND_IMAGE, (config.SCREEN_WIDTH, config.GAME_AREA_HEIGHT))
self.instruction = self._load_image(config.TUTORIAL_IMAGE, (config.SCREEN_WIDTH, config.SCREEN_HEIGHT))
self._load_case_images()
self._load_sounds()
self._load_beeps()
def _load_font(self, size: int) -> pygame.font.Font:
try:
return pygame.font.Font(str(config.FONT_REGULAR), size)
except (OSError, pygame.error):
return pygame.font.SysFont(None, size)
@staticmethod
def _load_image(path, size: tuple[int, int] | None = None) -> pygame.Surface | None:
if not path.exists():
return None
try:
image = pygame.image.load(str(path))
image = image.convert_alpha() if image.get_alpha() is not None else image.convert()
except pygame.error:
return None
if size is not None:
image = pygame.transform.smoothscale(image, size)
return image
def _load_case_images(self) -> None:
if not config.IMAGES_DIR.is_dir():
return
for path in sorted(config.IMAGES_DIR.glob("case_w*_h*.png")):
match = _CASE_FILENAME.match(path.name)
if not match:
continue
key = (int(match.group(1)), int(match.group(2)))
try:
self._case_originals[key] = pygame.image.load(str(path)).convert_alpha()
except pygame.error:
continue
def _load_sounds(self) -> None:
if not pygame.mixer.get_init():
return
for name, path in config.SOUND_FILES.items():
if not path.exists():
continue
try:
self.sounds[name] = pygame.mixer.Sound(str(path))
except pygame.error:
continue
if "roll" in self.sounds:
self.sounds["roll"].set_volume(0.3)
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)
def _load_beeps(self) -> None:
if not pygame.mixer.get_init():
return
for index, spec in enumerate(config.INTRO_BEEPS, start=1):
self.sounds[f"beep{index}"] = self._make_beep(**spec)
# ------------------------------------------------------------------
# Koffer-Grafiken
# ------------------------------------------------------------------
def case_surface(self, units_w: int, units_h: int) -> pygame.Surface | None:
"""Skalierte Grafik für einen Koffer der Grösse ``units_w`` x ``units_h``.
Fehlt die passende Datei, wird die gespiegelte Variante um 90° gedreht.
Gibt ``None`` zurück, wenn es gar keine Grafik gibt.
"""
key = (units_w, units_h)
cached = self._case_cache.get(key)
if cached is not None:
return cached
size = (int(units_w * config.UNIT_LENGTH), int(units_h * config.UNIT_HEIGHT))
natural = self._case_originals.get(key)
surface: pygame.Surface | None
if natural is not None:
surface = pygame.transform.smoothscale(natural, size)
else:
mirrored_key = (units_h, units_w)
mirrored = self._case_originals.get(mirrored_key)
if mirrored is None:
return None
mirrored_size = (
int(units_h * config.UNIT_LENGTH),
int(units_w * config.UNIT_HEIGHT),
)
surface = pygame.transform.rotate(
pygame.transform.smoothscale(mirrored, mirrored_size), 90
)
self._case_cache[key] = surface
return surface
# ------------------------------------------------------------------
# Sound-Steuerung
# ------------------------------------------------------------------
def play(self, name: str, loops: int = 0) -> None:
sound = self.sounds.get(name)
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)
return
sound.play(loops=loops)
def stop_roll(self) -> None:
if self._roll_channel is not None:
self._roll_channel.stop()
def stop_all(self) -> None:
if pygame.mixer.get_init():
pygame.mixer.stop()
# ------------------------------------------------------------------
# Töne ohne Dateien (für das Intro)
# ------------------------------------------------------------------
@staticmethod
def _make_beep(frequency: int = 440, duration_ms: int = 500, volume: float = 0.5):
"""Erzeugt einen Sinuston als ``pygame.mixer.Sound`` (ohne numpy)."""
init = pygame.mixer.get_init()
if not init:
raise pygame.error("Mixer ist nicht initialisiert")
sample_rate, _, channels = init
sample_count = int(sample_rate * duration_ms / 1000.0)
buffer = array.array("h")
for index in range(sample_count):
value = int(math.sin(2 * math.pi * frequency * index / sample_rate) * volume * 32767)
for _ in range(channels):
buffer.append(value)
return pygame.mixer.Sound(buffer=buffer.tobytes())
+71
View File
@@ -0,0 +1,71 @@
"""Der Koffer: Geometrie und Einlaufbewegung.
Bewusst ohne ``pygame``-Abhängigkeit, damit die Regeln testbar bleiben.
"""
from __future__ import annotations
from dataclasses import dataclass
from . import config
@dataclass
class Case:
"""Ein Koffer im Auflieger.
``units_w`` / ``units_h`` beschreiben die **aktuelle** Grundfläche in
Kasten-Einheiten. Eine Drehung tauscht die beiden Werte – es gibt also
keinen zusätzlichen ``rotated``-Schalter, der mit den Massen kollidieren
könnte (das war im Altstand eine Fehlerquelle).
"""
units_w: int
units_h: int
x: float
y: float
color: tuple[int, int, int] = config.CASE_COLORS[0]
placed: bool = False
snapped: bool = False
allow_snap: bool = False
can_rotate: bool = True
# -- Geometrie ------------------------------------------------------
@property
def width(self) -> float:
return self.units_w * config.UNIT_LENGTH
@property
def height(self) -> float:
return self.units_h * config.UNIT_HEIGHT
@property
def right(self) -> float:
return self.x + self.width
@property
def bottom(self) -> float:
return self.y + self.height
# -- Bewegung -------------------------------------------------------
def move(self, dt: float, wall_x: float) -> None:
"""Bewegt den Koffer nach links, mit Abbremsen kurz vor der Barriere."""
if self.placed:
return
distance = self.x - wall_x
speed = (
config.CASE_SPEED_FAST
if distance > config.FAST_TO_SLOW_DISTANCE
else config.CASE_SPEED_SLOW
)
self.x -= speed * dt
if self.x <= wall_x + config.SNAP_THRESHOLD:
self.allow_snap = True
def rotate_footprint(self) -> None:
"""Tauscht Breite und Höhe (nach einer 90°-Drehung)."""
self.units_w, self.units_h = self.units_h, self.units_w
def fits_trailer_height(self) -> bool:
"""Passt der Koffer nach einer Drehung noch in die Höhe des Aufliegers?"""
return self.width <= config.TRAILER_HEIGHT
+151
View File
@@ -0,0 +1,151 @@
"""Zentrale Konfiguration: alle Masse, Farben, Tempi und Pfade.
Alle Geometrieangaben beziehen sich auf eine virtuelle Auflösung von
1920x1080. Das Fenster wird per ``pygame.SCALED`` automatisch auf die
tatsächliche Fenstergrösse skaliert – dadurch sieht das Spiel auf einem
MacBook (Retina, 1440x900, …) genauso aus wie auf einem 1080p-Monitor.
"""
from __future__ import annotations
import os
from pathlib import Path
# --------------------------------------------------------------------------
# Pfade
# --------------------------------------------------------------------------
BASE_DIR = Path(__file__).resolve().parent.parent
RUNTIME_DIR = BASE_DIR / "runtime"
IMAGES_DIR = BASE_DIR / "images"
FONTS_DIR = BASE_DIR / "fonts"
FONT_REGULAR = FONTS_DIR / "Tisa Sans Pro Regular.ttf"
BACKGROUND_IMAGE = IMAGES_DIR / "background.png"
TUTORIAL_IMAGE = IMAGES_DIR / "tutorial.png"
SEQUENCES_FILE = BASE_DIR / "case_sequences.txt"
SOUND_FILES = {
"roll": BASE_DIR / "roll.ogg",
"place": BASE_DIR / "place.ogg",
"fail": BASE_DIR / "fail.ogg",
"zip": BASE_DIR / "zip.ogg",
}
# --------------------------------------------------------------------------
# Fenster / Bildrate
# --------------------------------------------------------------------------
SCREEN_WIDTH = 1920
SCREEN_HEIGHT = 1080
WINDOW_TITLE = "Habegger Trailer Load"
FPS = 60
AUDIO_SAMPLE_RATE = 44100
DEFAULT_FULLSCREEN = False # per --fullscreen oder F11 umschaltbar
# --------------------------------------------------------------------------
# Layout (horizontale Bänder)
# --------------------------------------------------------------------------
TOP_BAR_HEIGHT = SCREEN_HEIGHT // 8 # 135
GAME_AREA_HEIGHT = int(SCREEN_HEIGHT / 1.6) # 675
BOTTOM_BAR_HEIGHT = SCREEN_HEIGHT // 4 # 270
# --------------------------------------------------------------------------
# Auflieger (grauer Kasten, in dem die Koffer gestapelt werden)
# --------------------------------------------------------------------------
TRAILER_WIDTH = int(SCREEN_WIDTH * 0.885416666667) # 1700
TRAILER_HEIGHT = int(SCREEN_HEIGHT * 0.2778) # 300
TRAILER_X = (SCREEN_WIDTH - TRAILER_WIDTH) // 4 # 55
TRAILER_Y = TOP_BAR_HEIGHT + 200 # 335
TRAILER_RIGHT = TRAILER_X + TRAILER_WIDTH
TRAILER_BOTTOM = TRAILER_Y + TRAILER_HEIGHT
# Eine Kasten-Einheit entspricht 100x100 Pixeln.
UNIT_LENGTH = TRAILER_HEIGHT // 3 # 100
UNIT_HEIGHT = TRAILER_HEIGHT // 3 # 100
MAX_UNITS_W = 4
MAX_UNITS_H = 3
# --------------------------------------------------------------------------
# Spieltempo (jetzt in Pixel pro Sekunde statt pro Frame)
# --------------------------------------------------------------------------
CASE_SPEED_FAST = 720 # früher: 6 px/Frame bei 120 fps
CASE_SPEED_SLOW = 240 # früher: 2 px/Frame bei 120 fps
FAST_TO_SLOW_DISTANCE = 120 # ab hier wird abgebremst
SNAP_THRESHOLD = 60 # ab hier darf eingerastet werden
PLATFORM_TOLERANCE = 5 # Toleranz für "bündig an der Barriere"
# Animationsdauern in Sekunden (vorher: 10 Frames à 16,6 ms)
ANIM_SNAP = 0.17
ANIM_ROTATE = 0.18
ANIM_STACK_STEP = 0.17
SHAKE_TIME = 0.12
SHAKE_AMPLITUDE = 5
# --------------------------------------------------------------------------
# Intro
# --------------------------------------------------------------------------
INTRO_DOT_COLORS = ((255, 0, 0), (255, 200, 0), (0, 255, 0))
INTRO_DOT_DELAY = 1.0
INTRO_DOT_RADIUS = 90
INTRO_ZIP_DURATION = 0.2
INTRO_BEEPS = (
{"frequency": 440, "duration_ms": 600, "volume": 0.5},
{"frequency": 440, "duration_ms": 400, "volume": 0.5},
{"frequency": 600, "duration_ms": 1000, "volume": 0.5},
)
# --------------------------------------------------------------------------
# Farben
# --------------------------------------------------------------------------
COLOR_TOP_BAR = (0, 0, 0)
COLOR_BOTTOM_BAR = (0, 0, 0)
COLOR_BACKGROUND = (0, 0, 0)
COLOR_TRAILER = (100, 100, 100)
COLOR_WALL = (255, 0, 0)
COLOR_WHITE = (255, 255, 255)
COLOR_HINT_SNAP = (0, 255, 0)
COLOR_HINT_STACK = (255, 255, 0)
COLOR_ERROR = (255, 100, 100)
COLOR_PROGRESS_TRACK = (50, 50, 50)
CASE_COLORS = ((200, 0, 0), (0, 200, 0), (0, 0, 200))
# --------------------------------------------------------------------------
# Fortschrittsbalken
# --------------------------------------------------------------------------
PROGRESS_BAR_SEGMENTS = 5
PROGRESS_BAR_WIDTH = int(SCREEN_WIDTH * 0.25)
PROGRESS_BAR_HEIGHT = 30
PROGRESS_BAR_RIGHT_MARGIN = 140
PROGRESS_BAR_GAP = 8
PROGRESS_BAR_RADIUS = 10
# --------------------------------------------------------------------------
# Texte
# --------------------------------------------------------------------------
FONT_SIZE = 48
FONT_SIZE_SMALL = 34
TITLE_TEXT = WINDOW_TITLE
NEXT_LABEL = "Next:"
NEXT_PREVIEW_COUNT = 3
NEXT_PREVIEW_SCALE = 0.6 # Vorschau-Koffer sind kleiner als im Auflieger
NEXT_PREVIEW_MARGIN = 20
HINT_SNAP = "Snap bereit!"
HINT_STACK = "OnTop möglich!"
GAME_OVER_GOOD = "Gratuliere {name}! Du hast {percent}% geladen!"
GAME_OVER_NORMAL = "{name}, du hast {percent}% geladen."
GAME_OVER_HINT = "Drücke A oder SPACE um zu beenden."
GAME_OVER_GOOD_PERCENT = 75
# --------------------------------------------------------------------------
# Spieler / Punkte-Server
# --------------------------------------------------------------------------
DEFAULT_PLAYER_NAME = "Spieler"
MAX_PLAYER_NAME_LENGTH = 14
# Der Punkte-Upload ist standardmässig deaktiviert. Zum Aktivieren:
# export HAG_SCORE_URL="https://…/api/submit_points.php"
# export HAG_SCORE_API_KEY="…"
# (Schlüssel gehören in die Umgebung, nicht in den Quellcode.)
SCORE_URL = os.environ.get("HAG_SCORE_URL", "")
SCORE_API_KEY = os.environ.get("HAG_SCORE_API_KEY", "")
SCORE_TIMEOUT = 5.0
+333
View File
@@ -0,0 +1,333 @@
"""Zustandsmaschine des Spiels: Koffer einlaufen lassen, stapeln, auswerten."""
from __future__ import annotations
import enum
import random
from dataclasses import dataclass
from . import config, scene
from .case import Case
from .input_map import Action
from .net import submit_score
from .sequences import CaseSpec, FALLBACK_SEQUENCE
from .trailer import Trailer
class State(enum.Enum):
PLAYING = "playing"
GAME_OVER = "game_over"
@dataclass
class Animation:
"""Kurze Zwischenanimation für den aktuellen Koffer."""
kind: str # "snap" | "rotate" | "stack"
duration: float
start_x: float
start_y: float
target_x: float = 0.0
target_y: float = 0.0
base: Case | None = None
elapsed: float = 0.0
@property
def progress(self) -> float:
if self.duration <= 0:
return 1.0
return min(1.0, self.elapsed / self.duration)
class Game:
def __init__(
self,
assets,
sequences,
*,
name: str = config.DEFAULT_PLAYER_NAME,
rng: random.Random | None = None,
submit_scores: bool = True,
) -> None:
self.assets = assets
self.rng = rng or random.Random()
self.name = name
self.submit_scores = submit_scores
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.index = 0
self.current: Case | None = None
self.animation: Animation | None = None
self.shake = 0.0
self.percent = 0.0
self.status_line = ""
self.status_is_error = False
self._spawn()
# ------------------------------------------------------------------
# Sequenz / Nachschub
# ------------------------------------------------------------------
def has_next(self) -> bool:
return self.index < len(self.sequence)
def upcoming(self, count: int = config.NEXT_PREVIEW_COUNT) -> list[CaseSpec]:
"""Die nächsten Koffer für die "Next"-Vorschau."""
return self.sequence[self.index : self.index + count]
def _spawn(self) -> None:
if not self.has_next():
self.current = None
self._finish()
return
spec = self.sequence[self.index]
self.index += 1
self.current = Case(
units_w=spec.units_w,
units_h=spec.units_h,
x=float(config.SCREEN_WIDTH),
y=float(config.TRAILER_BOTTOM - spec.units_h * config.UNIT_HEIGHT),
color=self.rng.choice(config.CASE_COLORS),
)
self._play("roll", loops=-1)
# ------------------------------------------------------------------
# Eingabe
# ------------------------------------------------------------------
def handle(self, action: Action) -> bool:
"""Verarbeitet eine Aktion. Rückgabe ``False`` = Spiel beenden."""
if action is Action.BACK:
return False
if self.state is State.GAME_OVER:
return action is not Action.PRIMARY
if self.animation is not None or self.current is None:
return True
case = self.current
if case.placed or not case.allow_snap:
return True
if action is Action.PRIMARY:
self._start_snap(case)
elif action is Action.LEFT:
self._start_rotate(case)
elif action is Action.UP:
self._start_stack(case)
return True
def _start_snap(self, case: Case) -> None:
self._stop_roll()
self.animation = Animation(
kind="snap",
duration=config.ANIM_SNAP,
start_x=case.x,
start_y=case.y,
target_x=self.trailer.wall_x,
target_y=case.y,
)
def _start_rotate(self, case: Case) -> None:
if not case.can_rotate or not case.fits_trailer_height():
return
self._stop_roll()
self.animation = Animation(
kind="rotate",
duration=config.ANIM_ROTATE,
start_x=case.x,
start_y=case.y,
)
def _start_stack(self, case: Case) -> None:
base = self.trailer.find_platform(case)
if base is not None:
self._stop_roll()
self.animation = Animation(
kind="stack",
duration=config.ANIM_STACK_STEP * 2,
start_x=case.x,
start_y=case.y,
target_x=base.right - case.width,
target_y=base.y - case.height,
base=base,
)
return
# Kein Platz obendrauf: wie im Altstand wird der Koffer mit Lücke
# eingereiht und das Spiel schüttelt sich.
self._stack_with_gap(case, sound="place")
# ------------------------------------------------------------------
# Update
# ------------------------------------------------------------------
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:
return
if self.animation is not None:
self._advance_animation(dt)
return
case = self.current
if case is None:
self._finish()
return
case.move(dt, self.trailer.wall_x)
if not case.placed and case.x <= self.trailer.wall_x:
self._stack_with_gap(case, sound="fail")
return
self._check_dead_end(case)
def _advance_animation(self, dt: float) -> None:
animation = self.animation
case = self.current
if animation is None or case is None:
self.animation = None
return
animation.elapsed += dt
progress = animation.progress
if animation.kind == "snap":
case.x = animation.start_x + (animation.target_x - animation.start_x) * progress
elif animation.kind == "stack":
if progress <= 0.5:
factor = progress / 0.5
case.y = animation.start_y + (animation.target_y - animation.start_y) * factor
else:
factor = (progress - 0.5) / 0.5
case.y = animation.target_y
case.x = animation.start_x + (animation.target_x - animation.start_x) * factor
if progress >= 1.0:
self._finish_animation(animation)
def _finish_animation(self, animation: Animation) -> None:
self.animation = None
case = self.current
if case is None:
return
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.can_rotate = False
return
case.x, case.y = animation.target_x, animation.target_y
if animation.kind == "snap":
self.trailer.stack_at_wall(case)
else:
self.trailer.place_on_top(case, animation.base)
self._play("place")
self._next_round()
def _stack_with_gap(self, case: Case, *, sound: str) -> None:
self._stop_roll()
self.trailer.stack_with_gap(case)
if case.right > self.trailer.right:
self.trailer.remove(case)
self._play(sound)
self.shake = config.SHAKE_TIME
self._next_round()
def _next_round(self) -> None:
if self.trailer.overflow():
self._finish()
return
self._spawn()
def _check_dead_end(self, case: Case) -> None:
"""Ist der Koffer weder einrastbar, noch stapelbar, noch drehbar?"""
if case.allow_snap:
return
if self.trailer.wall_x + case.width <= self.trailer.right:
return
if self.trailer.find_platform(case) is not None:
return
if case.can_rotate and case.fits_trailer_height():
return
self._finish()
def _finish(self) -> None:
if self.state is State.GAME_OVER:
return
self.state = State.GAME_OVER
self._stop_roll()
self.percent = self.trailer.load_percent()
if self.percent >= 99.9:
self.percent = 100.0
if self.submit_scores:
ok, message = submit_score(self.name, self.percent)
if ok or config.SCORE_URL:
self.status_line = message
self.status_is_error = not ok
# ------------------------------------------------------------------
# Zeichnen
# ------------------------------------------------------------------
def draw(self, surface) -> None:
assets = self.assets
scene.draw_top_bar(surface, assets, self.trailer.load_percent())
scene.draw_background(surface, assets)
offset = (0, 0)
if self.shake > 0:
offset = (
self.rng.randint(-config.SHAKE_AMPLITUDE, config.SHAKE_AMPLITUDE),
self.rng.randint(-config.SHAKE_AMPLITUDE, config.SHAKE_AMPLITUDE),
)
scene.draw_trailer(surface, offset)
for stacked in self.trailer.stack:
scene.draw_case(surface, assets, stacked)
case = self.current
if case is not None:
angle = 0.0
if self.animation is not None and self.animation.kind == "rotate":
angle = -90.0 * self.animation.progress
scene.draw_case(surface, assets, case, angle=angle)
scene.draw_wall(surface, self.trailer.wall_x, offset)
scene.draw_bottom_bar(surface, assets, self.upcoming())
if case is not None and case.allow_snap and self.state is State.PLAYING:
scene.draw_hint(
surface, assets, self.trailer.find_platform(case) is not None
)
if self.state is State.GAME_OVER:
scene.draw_overlay_lines(
surface,
assets,
self.game_over_lines(),
status=self.status_line,
status_color=config.COLOR_ERROR if self.status_is_error else config.COLOR_HINT_SNAP,
)
def game_over_lines(self) -> list[str]:
percent = int(self.percent)
if self.percent >= config.GAME_OVER_GOOD_PERCENT:
first = config.GAME_OVER_GOOD.format(name=self.name, percent=percent)
else:
first = config.GAME_OVER_NORMAL.format(name=self.name, percent=percent)
return [first, config.GAME_OVER_HINT]
# ------------------------------------------------------------------
# Hilfen
# ------------------------------------------------------------------
def _play(self, name: str, loops: int = 0) -> None:
if self.assets is not None:
self.assets.play(name, loops=loops)
def _stop_roll(self) -> None:
if self.assets is not None:
self.assets.stop_roll()
+108
View File
@@ -0,0 +1,108 @@
"""Tastatur und Controller → abstrakte Aktionen.
Die Aktionen sind absichtlich generisch gehalten. Die spätere Namenseingabe
per Controller benutzt dieselben Richtungen (Buchstabe weiter/zurück,
Position wechseln), deshalb gibt es hier keine spielspezifischen Namen.
"""
from __future__ import annotations
import enum
import pygame
from . import config
class Action(enum.Enum):
"""Abstrakte Eingabe – unabhängig davon, ob Tastatur oder Controller."""
PRIMARY = "primary" # Space / Enter / Controller A
LEFT = "left" # Pfeil links / D-Pad links
RIGHT = "right" # Pfeil rechts / D-Pad rechts
UP = "up" # Pfeil hoch / D-Pad hoch
DOWN = "down" # Pfeil runter / D-Pad runter
BACK = "back" # Esc / Controller B
#: Tastaturbelegung
KEY_ACTIONS: dict[int, Action] = {
pygame.K_SPACE: Action.PRIMARY,
pygame.K_RETURN: Action.PRIMARY,
pygame.K_KP_ENTER: Action.PRIMARY,
pygame.K_LEFT: Action.LEFT,
pygame.K_RIGHT: Action.RIGHT,
pygame.K_UP: Action.UP,
pygame.K_DOWN: Action.DOWN,
pygame.K_ESCAPE: Action.BACK,
}
#: Controller-Tasten (Xbox-Layout: 0=A, 1=B, 2=X, 3=Y)
BUTTON_ACTIONS: dict[int, Action] = {
0: Action.PRIMARY,
1: Action.BACK,
2: Action.LEFT,
3: Action.UP,
}
#: D-Pad, das als Hat gemeldet wird
HAT_ACTIONS: dict[tuple[int, int], Action] = {
(-1, 0): Action.LEFT,
(1, 0): Action.RIGHT,
(0, -1): Action.UP,
(0, 1): Action.DOWN,
}
#: Analogstick: ab diesem Ausschlag gilt eine Richtung als gewollt
AXIS_DEADZONE = 0.6
def actions_from_event(event: pygame.event.Event) -> list[Action]:
"""Übersetzt ein pygame-Event in null bis eine Aktion."""
if event.type == pygame.KEYDOWN:
action = KEY_ACTIONS.get(event.key)
return [action] if action else []
if event.type == pygame.JOYBUTTONDOWN:
action = BUTTON_ACTIONS.get(event.button)
return [action] if action else []
if event.type == pygame.JOYHATMOTION:
action = HAT_ACTIONS.get(tuple(event.value))
return [action] if action else []
if event.type == pygame.JOYAXISMOTION:
if event.axis == 0 and abs(event.value) >= AXIS_DEADZONE:
return [Action.RIGHT if event.value > 0 else Action.LEFT]
if event.axis == 1 and abs(event.value) >= AXIS_DEADZONE:
return [Action.DOWN if event.value > 0 else Action.UP]
return []
def connected_controllers() -> list[str]:
"""Namen aller angeschlossenen Controller (leer, wenn keiner da ist)."""
names = []
for index in range(pygame.joystick.get_count()):
try:
stick = pygame.joystick.Joystick(index)
stick.init()
names.append(stick.get_name())
except pygame.error:
continue
return names
def describe_controller_setup() -> str:
names = connected_controllers()
if not names:
return "Controller: keiner erkannt (Tastatur: Space=Snap, ←=Drehen, ↑=OnTop, Esc=Ende)"
return "Controller: " + ", ".join(names)
def controller_hint_lines() -> list[str]:
"""Kurzhilfe fürs Log beim Start."""
return [
describe_controller_setup(),
f"Zielauflösung {config.SCREEN_WIDTH}x{config.SCREEN_HEIGHT}, "
f"{config.FPS} fps",
]
+82
View File
@@ -0,0 +1,82 @@
"""Tutorialbild, Ampel-Startsequenz und Reissverschluss-Effekt."""
from __future__ import annotations
import pygame
from . import config, scene
from .input_map import Action
class Intro:
"""Vorspann vor dem ersten Koffer.
Ablauf: Tutorialbild (wartet auf Bestätigung) → drei Punkte mit Ton →
Reissverschluss, der den Auflieger "öffnet".
"""
def __init__(self, assets) -> None:
self.assets = assets
self.phase = "instruction" if assets.instruction is not None else "countdown"
self.timer = 0.0
self.dot_index = 0
if self.phase == "countdown":
self._start_countdown()
# ------------------------------------------------------------------
def _start_countdown(self) -> None:
self.phase = "countdown"
self.timer = 0.0
self.dot_index = 0
self.assets.play("beep1")
def handle(self, action: Action) -> None:
if self.phase == "instruction" and action is Action.PRIMARY:
self._start_countdown()
def update(self, dt: float) -> bool:
"""Weiterrechnen; Rückgabe ``True``, wenn der Vorspann fertig ist."""
self.timer += dt
if self.phase == "countdown":
index = int(self.timer // config.INTRO_DOT_DELAY)
if index >= len(config.INTRO_DOT_COLORS):
self.phase = "zip"
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, [])
if self.phase == "countdown":
if 0 <= self.dot_index < len(config.INTRO_DOT_COLORS):
pygame.draw.circle(
surface,
config.INTRO_DOT_COLORS[self.dot_index],
(
config.TRAILER_X + config.TRAILER_WIDTH // 2,
config.TRAILER_Y + config.TRAILER_HEIGHT // 2,
),
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
width = int(config.TRAILER_WIDTH * progress)
pygame.draw.rect(
surface,
config.COLOR_TRAILER,
(config.TRAILER_X, config.TRAILER_Y, width, config.TRAILER_HEIGHT),
)
+47
View File
@@ -0,0 +1,47 @@
"""Optionaler Punkte-Upload.
Standardmässig **deaktiviert**: Es passiert nichts, solange ``HAG_SCORE_URL``
nicht gesetzt ist. Zugangsdaten kommen aus der Umgebung
(``HAG_SCORE_API_KEY``) – nicht aus dem Quellcode.
"""
from __future__ import annotations
import urllib.error
import urllib.parse
import urllib.request
from . import config
def submit_score(
name: str,
percent: float,
*,
url: str | None = None,
api_key: str | None = None,
timeout: float | None = None,
) -> tuple[bool, str]:
"""Sendet das Ergebnis. Rückgabe: ``(erfolgreich, meldung)``."""
endpoint = url if url is not None else config.SCORE_URL
if not endpoint:
return False, ""
payload = urllib.parse.urlencode(
{
"name": name,
"score": int(percent),
"api_key": api_key if api_key is not None else config.SCORE_API_KEY,
}
).encode("utf-8")
try:
request = urllib.request.Request(endpoint, data=payload)
with urllib.request.urlopen(request, timeout=timeout or config.SCORE_TIMEOUT) as response:
body = response.read().decode("utf-8", "replace")
except (urllib.error.URLError, OSError, ValueError) as exc:
return False, f"Punkte konnten nicht gesendet werden: {exc}"
if "success" not in body.lower():
return False, "Punkte konnten nicht gesendet werden (unerwartete Serverantwort)."
return True, "Punkte gesendet."
+205
View File
@@ -0,0 +1,205 @@
"""Zeichenroutinen – alles, was Pixel auf den Bildschirm bringt."""
from __future__ import annotations
import pygame
from . import config
from .case import Case
from .sequences import CaseSpec
# --------------------------------------------------------------------------
# Hintergrund-Bänder
# --------------------------------------------------------------------------
def draw_top_bar(surface: pygame.Surface, assets, percent: float) -> None:
surface.fill(
config.COLOR_TOP_BAR, (0, 0, config.SCREEN_WIDTH, config.TOP_BAR_HEIGHT)
)
font = assets.font
title = font.render(config.TITLE_TEXT, True, config.COLOR_WHITE)
surface.blit(title, (40, config.TOP_BAR_HEIGHT // 2 - title.get_height() // 2))
_draw_progress_bar(surface, assets, percent)
def _draw_progress_bar(surface: pygame.Surface, assets, percent: float) -> None:
bar_width = config.PROGRESS_BAR_WIDTH
bar_height = config.PROGRESS_BAR_HEIGHT
x = config.SCREEN_WIDTH - bar_width - config.PROGRESS_BAR_RIGHT_MARGIN
y = config.TOP_BAR_HEIGHT // 2 - bar_height // 2
gap = config.PROGRESS_BAR_GAP
segment_width = (bar_width - (config.PROGRESS_BAR_SEGMENTS - 1) * gap) / config.PROGRESS_BAR_SEGMENTS
red = min(255, int(percent * 2.55))
green = max(0, 255 - int(percent * 2.55))
color = (red, green, 0)
for index in range(config.PROGRESS_BAR_SEGMENTS):
segment_x = x + index * (segment_width + gap)
rect = (segment_x, y, segment_width, bar_height)
pygame.draw.rect(surface, config.COLOR_PROGRESS_TRACK, rect, border_radius=config.PROGRESS_BAR_RADIUS)
if percent >= (index + 1) * 20:
pygame.draw.rect(surface, color, rect, border_radius=config.PROGRESS_BAR_RADIUS)
text = assets.font.render(f"{int(percent)}%", True, config.COLOR_WHITE)
surface.blit(text, (x + bar_width + 10, y))
def draw_background(surface: pygame.Surface, assets) -> None:
if assets.background is not None:
surface.blit(assets.background, (0, config.TOP_BAR_HEIGHT))
else:
pygame.draw.rect(
surface,
config.COLOR_BACKGROUND,
(0, config.TOP_BAR_HEIGHT, config.SCREEN_WIDTH, config.GAME_AREA_HEIGHT),
)
def draw_bottom_bar(surface: pygame.Surface, assets, upcoming: list[CaseSpec]) -> None:
surface.fill(
config.COLOR_BOTTOM_BAR,
(0, config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT, config.SCREEN_WIDTH, config.BOTTOM_BAR_HEIGHT),
)
top = config.SCREEN_HEIGHT - config.BOTTOM_BAR_HEIGHT
label = (assets.font_small or assets.font).render(config.NEXT_LABEL, True, config.COLOR_WHITE)
surface.blit(label, (40, top + 12))
preview_x = 40
for index, spec in enumerate(upcoming[: config.NEXT_PREVIEW_COUNT]):
image = _preview_surface(assets, spec)
height = image.get_height() if image is not None else 0
y = config.SCREEN_HEIGHT - config.NEXT_PREVIEW_MARGIN - height
if image is None:
width, height = _spec_size(spec)
y = config.SCREEN_HEIGHT - config.NEXT_PREVIEW_MARGIN - height
pygame.draw.rect(
surface,
config.CASE_COLORS[index % len(config.CASE_COLORS)],
(preview_x, y, width, height),
)
else:
width = image.get_width()
surface.blit(image, (preview_x, y))
preview_x += max(width, 60) + 20
_PREVIEW_CACHE: dict[tuple[int, int], pygame.Surface | None] = {}
def _preview_surface(assets, spec: CaseSpec) -> pygame.Surface | None:
"""Vorschau-Grafik in verkleinertem Massstab (gecacht)."""
key = (spec.units_w, spec.units_h)
if key in _PREVIEW_CACHE:
return _PREVIEW_CACHE[key]
image = assets.case_surface(spec.units_w, spec.units_h) if assets is not None else None
if image is not None:
size = (
max(1, int(image.get_width() * config.NEXT_PREVIEW_SCALE)),
max(1, int(image.get_height() * config.NEXT_PREVIEW_SCALE)),
)
image = pygame.transform.smoothscale(image, size)
_PREVIEW_CACHE[key] = image
return image
# --------------------------------------------------------------------------
# Auflieger
# --------------------------------------------------------------------------
def draw_trailer(surface: pygame.Surface, offset: tuple[int, int] = (0, 0)) -> None:
pygame.draw.rect(
surface,
config.COLOR_TRAILER,
(
config.TRAILER_X + offset[0],
config.TRAILER_Y + offset[1],
config.TRAILER_WIDTH,
config.TRAILER_HEIGHT,
),
)
def draw_wall(surface: pygame.Surface, wall_x: float, offset: tuple[int, int] = (0, 0)) -> None:
pygame.draw.line(
surface,
config.COLOR_WALL,
(wall_x + offset[0], config.TRAILER_Y + offset[1]),
(wall_x + offset[0], config.TRAILER_BOTTOM + offset[1]),
3,
)
# --------------------------------------------------------------------------
# Koffer
# --------------------------------------------------------------------------
def _spec_size(spec: CaseSpec) -> tuple[int, int]:
return (
int(spec.units_w * config.UNIT_LENGTH),
int(spec.units_h * config.UNIT_HEIGHT),
)
def draw_case(surface: pygame.Surface, assets, case: Case, angle: float = 0.0) -> None:
x, y = int(case.x), int(case.y)
width, height = int(case.width), int(case.height)
image = assets.case_surface(case.units_w, case.units_h) if assets is not None else None
if image is None:
pygame.draw.rect(surface, case.color, (x, y, width, height))
return
if angle:
rotated = pygame.transform.rotate(image, angle)
surface.blit(rotated, rotated.get_rect(bottomleft=(x, y + height)))
else:
surface.blit(image, (x, y))
# --------------------------------------------------------------------------
# Hinweise und Overlays
# --------------------------------------------------------------------------
def draw_hint(surface: pygame.Surface, assets, stack_possible: bool) -> None:
if stack_possible:
text, color = config.HINT_STACK, config.COLOR_HINT_STACK
else:
text, color = config.HINT_SNAP, config.COLOR_HINT_SNAP
rendered = assets.font.render(text, True, color)
surface.blit(
rendered,
(
config.SCREEN_WIDTH - config.SCREEN_WIDTH // 3 - rendered.get_width() // 2,
config.SCREEN_HEIGHT - config.BOTTOM_BAR_HEIGHT // 2 - rendered.get_height() // 2,
),
)
def draw_overlay_lines(
surface: pygame.Surface,
assets,
lines: list[str],
*,
status: str = "",
status_color: tuple[int, int, int] = config.COLOR_ERROR,
) -> None:
overlay = pygame.Surface((config.SCREEN_WIDTH, config.SCREEN_HEIGHT))
overlay.set_alpha(220)
overlay.fill((0, 0, 0))
surface.blit(overlay, (0, 0))
center_y = config.SCREEN_HEIGHT // 2
line_gap = 16
total_height = 0
rendered: list[pygame.Surface] = []
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 image in rendered:
surface.blit(image, (config.SCREEN_WIDTH // 2 - image.get_width() // 2, y))
y += image.get_height() + line_gap
if status:
small = assets.font_small.render(status, True, status_color)
surface.blit(
small,
(config.SCREEN_WIDTH // 2 - small.get_width() // 2, y + 20),
)
+107
View File
@@ -0,0 +1,107 @@
"""Einlesen der Koffersequenzen aus ``case_sequences.txt``.
Dateiformat
-----------
Sequenzen sind durch Leerzeilen getrennt. Jede Zeile beschreibt einen Koffer::
breite,höhe,rotiert
* ``breite`` / ``höhe`` sind Kasten-Einheiten (1 Einheit = 100x100 Pixel).
* ``rotiert=True`` heisst: der Koffer wird beim Einlaufen um 90° gedreht,
Breite und Höhe werden also getauscht.
Beispiel::
3,1,False
1,2,True -> Koffer ist effektiv 2 Einheiten breit und 1 hoch
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from . import config
_TRUE_VALUES = {"true", "1", "ja", "yes", "x"}
@dataclass(frozen=True, slots=True)
class CaseSpec:
"""Ein Koffer aus der Sequenzdatei – bereits in Einlauf-Orientierung."""
units_w: int
units_h: int
def __post_init__(self) -> None:
if self.units_w < 1 or self.units_h < 1:
raise ValueError(f"Koffer muss mindestens 1x1 Einheiten gross sein: {self!r}")
@property
def label(self) -> str:
return f"{self.units_w}x{self.units_h}"
#: Notfall-Sequenz, falls die Datei fehlt oder unlesbar ist.
FALLBACK_SEQUENCE: tuple[CaseSpec, ...] = (CaseSpec(2, 1), CaseSpec(1, 2))
def parse_sequences(raw: str) -> tuple[list[list[CaseSpec]], list[str]]:
"""Zerlegt den Dateiinhalt in Sequenzen.
Rückgabe: ``(sequenzen, warnungen)``. Ungültige Zeilen werden gemeldet und
übersprungen – ein Tippfehler in der Datei soll das Spiel nicht verhindern.
"""
sequences: list[list[CaseSpec]] = []
warnings: list[str] = []
for block_number, block in enumerate(re.split(r"\n\s*\n", raw.strip()), start=1):
specs: list[CaseSpec] = []
for line_number, line in enumerate(block.splitlines(), start=1):
text = line.strip()
if not text:
continue
parts = [part.strip() for part in text.split(",")]
if len(parts) != 3:
warnings.append(
f"Block {block_number}, Zeile {line_number}: "
f"'breite,höhe,rotiert' erwartet, gefunden {text!r}"
)
continue
try:
units_w, units_h = int(parts[0]), int(parts[1])
except ValueError:
warnings.append(
f"Block {block_number}, Zeile {line_number}: keine ganzen Zahlen in {text!r}"
)
continue
rotated = parts[2].lower() in _TRUE_VALUES
if rotated:
units_w, units_h = units_h, units_w
if units_w < 1 or units_h < 1:
warnings.append(
f"Block {block_number}, Zeile {line_number}: ungültige Masse {text!r}"
)
continue
specs.append(CaseSpec(units_w, units_h))
if specs:
sequences.append(specs)
if not sequences:
warnings.append("Keine gültige Sequenz gefunden – es wird die Notfall-Sequenz benutzt.")
return sequences, warnings
def load_sequences(path: Path | None = None) -> tuple[list[list[CaseSpec]], list[str]]:
"""Liest die Sequenzdatei und liefert immer mindestens eine Sequenz."""
path = path or config.SEQUENCES_FILE
try:
raw = path.read_text(encoding="utf-8")
except OSError as exc:
return [list(FALLBACK_SEQUENCE)], [f"Sequenzdatei {path} nicht lesbar: {exc}"]
sequences, warnings = parse_sequences(raw)
if not sequences:
sequences = [list(FALLBACK_SEQUENCE)]
return sequences, warnings
+50
View File
@@ -0,0 +1,50 @@
"""Laufzeitdaten (letzter Spielername).
Alles, was zur Laufzeit geschrieben wird, landet in ``runtime/`` – dieser
Ordner ist in ``.gitignore`` eingetragen. Über die Umgebungsvariable
``HAG_RUNTIME_DIR`` lässt sich der Ort umbiegen (praktisch für Tests).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from . import config
LAST_PLAYER_FILE = "last_player.json"
def runtime_dir() -> Path:
"""Verzeichnis für Laufzeitdaten; wird bei Bedarf angelegt."""
override = os.environ.get("HAG_RUNTIME_DIR")
path = Path(override) if override else config.RUNTIME_DIR
path.mkdir(parents=True, exist_ok=True)
return path
def load_last_player() -> str | None:
try:
data = json.loads((runtime_dir() / LAST_PLAYER_FILE).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict):
return None
name = str(data.get("name", "")).strip()
return name or None
def save_last_player(name: str) -> None:
name = name.strip()[: config.MAX_PLAYER_NAME_LENGTH]
if not name:
return
try:
(runtime_dir() / LAST_PLAYER_FILE).write_text(
json.dumps({"name": name}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
except OSError:
# Laufzeitdaten sind nice-to-have – ein Schreibfehler darf das Spiel
# nicht stoppen.
pass
+124
View File
@@ -0,0 +1,124 @@
"""Stapelregeln im Auflieger – reine Logik, ohne pygame.
Begriffe
--------
Barriere (``wall_x``)
Rechte Kante des bereits gestapelten Ladeguts. Neue Koffer werden dort
eingerastet. Reicht die Barriere über das Heck des Aufliegers hinaus, ist
das Spiel verloren.
Plattform
Ein bereits platzierter Koffer, auf den der aktuelle Koffer obendrauf
gesetzt werden darf (nur bündig an der Barriere, nicht zu schmal, und die
Fläche obendrauf muss frei sein).
"""
from __future__ import annotations
from . import config
from .case import Case
class Trailer:
"""Der Auflieger samt Stapel und Barrieren-Logik."""
def __init__(
self,
x: int = config.TRAILER_X,
y: int = config.TRAILER_Y,
width: int = config.TRAILER_WIDTH,
height: int = config.TRAILER_HEIGHT,
) -> None:
self.x = x
self.y = y
self.width = width
self.height = height
self.stack: list[Case] = []
self.wall_x: float = float(x)
# ------------------------------------------------------------------
# Masse
# ------------------------------------------------------------------
@property
def right(self) -> float:
return self.x + self.width
@property
def bottom(self) -> float:
return self.y + self.height
def reset(self) -> None:
self.stack.clear()
self.wall_x = float(self.x)
# ------------------------------------------------------------------
# Auswertung
# ------------------------------------------------------------------
def overflow(self) -> bool:
"""Ragt die Barriere über das Heck hinaus?"""
return self.wall_x > self.right
def load_percent(self) -> float:
"""Beladung in Prozent (Fläche der Koffer / Fläche des Aufliegers)."""
total = self.width * self.height
if total <= 0:
return 0.0
used = sum(case.width * case.height for case in self.stack)
return min(100.0, used / total * 100.0)
def find_platform(self, case: Case) -> Case | None:
"""Koffer, auf den ``case`` obendrauf gesetzt werden darf (oder ``None``)."""
for base in reversed(self.stack):
if base.y <= self.y:
continue # Oberkante = Auflieger-Oberkante
if base.y - case.height < self.y:
continue # oben kein Platz mehr
if case.width > base.width:
continue # zu breit für die Plattform
if abs(base.right - self.wall_x) >= config.PLATFORM_TOLERANCE:
continue # nicht bündig an der Barriere
if self._top_is_busy(base):
continue
return base
return None
def _top_is_busy(self, base: Case) -> bool:
"""Liegt schon ein Koffer auf ``base``?"""
for other in self.stack:
if other is base:
continue
if abs(other.bottom - base.y) <= 0.5 and other.x < base.right and other.right > base.x:
return True
return False
# ------------------------------------------------------------------
# Platzieren
# ------------------------------------------------------------------
def stack_at_wall(self, case: Case) -> None:
"""Koffer bündig an die Barriere schieben."""
case.x = self.wall_x
case.placed = True
case.snapped = True
self.stack.append(case)
self.wall_x = case.right
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.placed = True
self.stack.append(case)
self.wall_x = case.right
def place_on_top(self, case: Case, base: Case) -> None:
"""Koffer rechtsbündig auf ``base`` ablegen."""
case.y = base.y - case.height
case.x = base.right - case.width
case.placed = True
case.snapped = True
self.stack.append(case)
self.wall_x = case.right
def remove(self, case: Case) -> None:
"""Koffer wieder entfernen (er ragt über das Heck hinaus)."""
if case in self.stack:
self.stack.remove(case)