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:
1 parent
6a2c891c3e
commit
de550e1d4d
24 files changed
+2269
-1008
No files matched your search
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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."
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user