v1.1: Stufen Easy/Normal/Hard mit Auswahlmenü (Hard = altes Tempo). Koffer behalten beim Drehen ihre Textur. Name/Punkteliste entfernt, nur Stufe + Prozent. Endlos: Ergebnis -> "Nochmals?" -> Stufenauswahl, Ende nur per Esc. storage.py und net.py gelöscht, 48 Tests.
This commit is contained in:
1 parent
de550e1d4d
commit
d970185e75
15 files changed
+613
-342
No files matched your search
+12
-12
@@ -2,17 +2,17 @@
|
||||
|
||||
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
|
||||
* :mod:`loader_game.config` – alle Masse, Farben, Texte, Pfade
|
||||
* :mod:`loader_game.difficulty` – Stufen Easy/Normal/Hard (Tempo)
|
||||
* :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, Textur, 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.menu` – Stufenauswahl
|
||||
* :mod:`loader_game.intro` – Tutorialbild + Ampel + Reissverschluss
|
||||
* :mod:`loader_game.game` – Zustandsmaschine einer Runde
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
+24
-6
@@ -8,6 +8,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import config
|
||||
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -15,9 +16,11 @@ 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).
|
||||
Kasten-Einheiten. Eine Drehung tauscht die beiden Werte.
|
||||
|
||||
``texture`` merkt sich die Orientierung, in der der Koffer eingelaufen ist.
|
||||
Die Grafik bleibt dadurch immer dieselbe – ein gedrehter Koffer wird nur um
|
||||
90° gedreht gezeichnet und bekommt keine andere Textur.
|
||||
"""
|
||||
|
||||
units_w: int
|
||||
@@ -25,11 +28,16 @@ class Case:
|
||||
x: float
|
||||
y: float
|
||||
color: tuple[int, int, int] = config.CASE_COLORS[0]
|
||||
texture: tuple[int, int] | None = None
|
||||
placed: bool = False
|
||||
snapped: bool = False
|
||||
allow_snap: bool = False
|
||||
can_rotate: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.texture is None:
|
||||
self.texture = (self.units_w, self.units_h)
|
||||
|
||||
# -- Geometrie ------------------------------------------------------
|
||||
@property
|
||||
def width(self) -> float:
|
||||
@@ -47,16 +55,26 @@ class Case:
|
||||
def bottom(self) -> float:
|
||||
return self.y + self.height
|
||||
|
||||
@property
|
||||
def is_rotated(self) -> bool:
|
||||
"""Steht der Koffer quer zu seiner Textur?"""
|
||||
return (self.units_w, self.units_h) != self.texture
|
||||
|
||||
# -- Bewegung -------------------------------------------------------
|
||||
def move(self, dt: float, wall_x: float) -> None:
|
||||
def move(
|
||||
self,
|
||||
dt: float,
|
||||
wall_x: float,
|
||||
difficulty: Difficulty = DEFAULT_DIFFICULTY,
|
||||
) -> 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
|
||||
difficulty.speed_fast
|
||||
if distance > config.FAST_TO_SLOW_DISTANCE
|
||||
else config.CASE_SPEED_SLOW
|
||||
else difficulty.speed_slow
|
||||
)
|
||||
self.x -= speed * dt
|
||||
if self.x <= wall_x + config.SNAP_THRESHOLD:
|
||||
|
||||
+21
-20
@@ -4,18 +4,18 @@ 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.
|
||||
|
||||
Die Spielgeschwindigkeit steht in :mod:`loader_game.difficulty`.
|
||||
"""
|
||||
|
||||
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"
|
||||
@@ -66,10 +66,8 @@ MAX_UNITS_W = 4
|
||||
MAX_UNITS_H = 3
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Spieltempo (jetzt in Pixel pro Sekunde statt pro Frame)
|
||||
# Spieltempo (Pixel pro Sekunde; Stufen siehe difficulty.py)
|
||||
# --------------------------------------------------------------------------
|
||||
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"
|
||||
@@ -105,7 +103,10 @@ 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_DIM = (25, 25, 25)
|
||||
COLOR_MENU_BOX = (60, 60, 60)
|
||||
COLOR_MENU_BOX_ACTIVE = (200, 165, 0)
|
||||
COLOR_MENU_BORDER = (120, 120, 120)
|
||||
COLOR_PROGRESS_TRACK = (50, 50, 50)
|
||||
CASE_COLORS = ((200, 0, 0), (0, 200, 0), (0, 0, 200))
|
||||
|
||||
@@ -125,27 +126,27 @@ PROGRESS_BAR_RADIUS = 10
|
||||
FONT_SIZE = 48
|
||||
FONT_SIZE_SMALL = 34
|
||||
TITLE_TEXT = WINDOW_TITLE
|
||||
SUBTITLE_TEMPLATE = "Stufe {difficulty}"
|
||||
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
|
||||
# Menü (Stufenauswahl)
|
||||
# --------------------------------------------------------------------------
|
||||
DEFAULT_PLAYER_NAME = "Spieler"
|
||||
MAX_PLAYER_NAME_LENGTH = 14
|
||||
MENU_TITLE = "Stufe wählen"
|
||||
MENU_HINT = "← → Stufe wählen A / SPACE starten"
|
||||
MENU_BOX_WIDTH = 320
|
||||
MENU_BOX_HEIGHT = 140
|
||||
MENU_BOX_GAP = 60
|
||||
MENU_BOX_BORDER = 5
|
||||
MENU_TOP = 470
|
||||
|
||||
# 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
|
||||
# --------------------------------------------------------------------------
|
||||
# Spielende
|
||||
# --------------------------------------------------------------------------
|
||||
RESULT_TEMPLATE = "Stufe {difficulty}: {percent}% Beladung"
|
||||
RESULT_HINT = "Nochmals? A / SPACE = neue Runde ESC = beenden"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Schwierigkeitsstufen: wie schnell die Koffer einlaufen.
|
||||
|
||||
``hard`` entspricht dem Tempo des Altstands (6 bzw. 2 Pixel pro Frame bei
|
||||
120 fps = 720 bzw. 240 Pixel pro Sekunde).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Difficulty:
|
||||
"""Eine Stufe. ``speed_fast`` gilt bis kurz vor der Barriere, dann ``speed_slow``."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
speed_fast: float
|
||||
speed_slow: float
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.label
|
||||
|
||||
|
||||
DIFFICULTIES: tuple[Difficulty, ...] = (
|
||||
Difficulty("easy", "Easy", speed_fast=420.0, speed_slow=140.0),
|
||||
Difficulty("normal", "Normal", speed_fast=570.0, speed_slow=190.0),
|
||||
Difficulty("hard", "Hard", speed_fast=720.0, speed_slow=240.0),
|
||||
)
|
||||
|
||||
DEFAULT_DIFFICULTY: Difficulty = DIFFICULTIES[1]
|
||||
|
||||
|
||||
def by_key(key: str) -> Difficulty:
|
||||
for difficulty in DIFFICULTIES:
|
||||
if difficulty.key == key:
|
||||
return difficulty
|
||||
return DEFAULT_DIFFICULTY
|
||||
+39
-40
@@ -8,15 +8,23 @@ from dataclasses import dataclass
|
||||
|
||||
from . import config, scene
|
||||
from .case import Case
|
||||
from .difficulty import DEFAULT_DIFFICULTY, Difficulty
|
||||
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"
|
||||
FINISHED = "finished"
|
||||
|
||||
|
||||
class HandleResult(enum.Enum):
|
||||
"""Was der Aufrufer nach einer Aktion tun soll."""
|
||||
|
||||
CONTINUE = "continue"
|
||||
NEW_ROUND = "new_round" # zurück zur Stufenauswahl
|
||||
QUIT = "quit" # Programm beenden (Esc / B)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -45,14 +53,12 @@ class Game:
|
||||
assets,
|
||||
sequences,
|
||||
*,
|
||||
name: str = config.DEFAULT_PLAYER_NAME,
|
||||
difficulty: Difficulty = DEFAULT_DIFFICULTY,
|
||||
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.difficulty = difficulty
|
||||
|
||||
self.state = State.PLAYING
|
||||
self.trailer = Trailer()
|
||||
@@ -64,8 +70,6 @@ class Game:
|
||||
self.animation: Animation | None = None
|
||||
self.shake = 0.0
|
||||
self.percent = 0.0
|
||||
self.status_line = ""
|
||||
self.status_is_error = False
|
||||
|
||||
self._spawn()
|
||||
|
||||
@@ -98,20 +102,21 @@ class Game:
|
||||
# ------------------------------------------------------------------
|
||||
# Eingabe
|
||||
# ------------------------------------------------------------------
|
||||
def handle(self, action: Action) -> bool:
|
||||
"""Verarbeitet eine Aktion. Rückgabe ``False`` = Spiel beenden."""
|
||||
def handle(self, action: Action) -> HandleResult:
|
||||
if action is Action.BACK:
|
||||
return False
|
||||
return HandleResult.QUIT
|
||||
|
||||
if self.state is State.GAME_OVER:
|
||||
return action is not Action.PRIMARY
|
||||
if self.state is State.FINISHED:
|
||||
if action is Action.PRIMARY:
|
||||
return HandleResult.NEW_ROUND
|
||||
return HandleResult.CONTINUE
|
||||
|
||||
if self.animation is not None or self.current is None:
|
||||
return True
|
||||
return HandleResult.CONTINUE
|
||||
|
||||
case = self.current
|
||||
if case.placed or not case.allow_snap:
|
||||
return True
|
||||
return HandleResult.CONTINUE
|
||||
|
||||
if action is Action.PRIMARY:
|
||||
self._start_snap(case)
|
||||
@@ -119,7 +124,7 @@ class Game:
|
||||
self._start_rotate(case)
|
||||
elif action is Action.UP:
|
||||
self._start_stack(case)
|
||||
return True
|
||||
return HandleResult.CONTINUE
|
||||
|
||||
def _start_snap(self, case: Case) -> None:
|
||||
self._stop_roll()
|
||||
@@ -179,7 +184,7 @@ class Game:
|
||||
self._finish()
|
||||
return
|
||||
|
||||
case.move(dt, self.trailer.wall_x)
|
||||
case.move(dt, self.trailer.wall_x, self.difficulty)
|
||||
if not case.placed and case.x <= self.trailer.wall_x:
|
||||
self._stack_with_gap(case, sound="fail")
|
||||
return
|
||||
@@ -258,25 +263,25 @@ class Game:
|
||||
self._finish()
|
||||
|
||||
def _finish(self) -> None:
|
||||
if self.state is State.GAME_OVER:
|
||||
if self.state is State.FINISHED:
|
||||
return
|
||||
self.state = State.GAME_OVER
|
||||
self.state = State.FINISHED
|
||||
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_top_bar(
|
||||
surface,
|
||||
assets,
|
||||
self.trailer.load_percent(),
|
||||
subtitle=config.SUBTITLE_TEMPLATE.format(difficulty=self.difficulty.label),
|
||||
)
|
||||
scene.draw_background(surface, assets)
|
||||
|
||||
offset = (0, 0)
|
||||
@@ -304,22 +309,16 @@ class Game:
|
||||
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,
|
||||
)
|
||||
if self.state is State.FINISHED:
|
||||
scene.draw_overlay_lines(surface, assets, self.result_lines())
|
||||
|
||||
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]
|
||||
def result_lines(self) -> list[str]:
|
||||
return [
|
||||
config.RESULT_TEMPLATE.format(
|
||||
difficulty=self.difficulty.label, percent=int(self.percent)
|
||||
),
|
||||
config.RESULT_HINT,
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hilfen
|
||||
|
||||
@@ -15,9 +15,10 @@ class Intro:
|
||||
Reissverschluss, der den Auflieger "öffnet".
|
||||
"""
|
||||
|
||||
def __init__(self, assets) -> None:
|
||||
def __init__(self, assets, *, show_instruction: bool = True) -> None:
|
||||
self.assets = assets
|
||||
self.phase = "instruction" if assets.instruction is not None else "countdown"
|
||||
show_tutorial = show_instruction and assets.instruction is not None
|
||||
self.phase = "instruction" if show_tutorial else "countdown"
|
||||
self.timer = 0.0
|
||||
self.dot_index = 0
|
||||
if self.phase == "countdown":
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Startmenü: Schwierigkeitsstufe wählen (Easy / Normal / Hard)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pygame
|
||||
|
||||
from . import config, scene
|
||||
from .difficulty import DEFAULT_DIFFICULTY, DIFFICULTIES, Difficulty
|
||||
from .input_map import Action
|
||||
|
||||
|
||||
class DifficultyMenu:
|
||||
"""Drei Kacheln, Auswahl mit ← / →, Start mit A / Space."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
assets,
|
||||
options: tuple[Difficulty, ...] = DIFFICULTIES,
|
||||
selected: Difficulty = DEFAULT_DIFFICULTY,
|
||||
) -> None:
|
||||
self.assets = assets
|
||||
self.options = list(options)
|
||||
self.index = self.options.index(selected) if selected in self.options else 0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def selected(self) -> Difficulty:
|
||||
return self.options[self.index]
|
||||
|
||||
def handle(self, action: Action) -> Difficulty | None:
|
||||
"""Rückgabe: gewählte Stufe, sobald bestätigt wurde – sonst ``None``."""
|
||||
if action is Action.LEFT:
|
||||
self.index = (self.index - 1) % len(self.options)
|
||||
elif action is Action.RIGHT:
|
||||
self.index = (self.index + 1) % len(self.options)
|
||||
elif action is Action.PRIMARY:
|
||||
return self.selected
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def draw(self, surface: pygame.Surface) -> None:
|
||||
scene.draw_top_bar(surface, self.assets, 0.0)
|
||||
scene.draw_background(surface, self.assets)
|
||||
scene.clear_bottom_bar(surface)
|
||||
scene.draw_dim(surface, 170)
|
||||
|
||||
font = self.assets.font
|
||||
small = self.assets.font_small or font
|
||||
|
||||
title = font.render(config.MENU_TITLE, True, config.COLOR_WHITE)
|
||||
surface.blit(
|
||||
title,
|
||||
(config.SCREEN_WIDTH // 2 - title.get_width() // 2, config.MENU_TOP - 110),
|
||||
)
|
||||
|
||||
total_width = len(self.options) * config.MENU_BOX_WIDTH + (
|
||||
len(self.options) - 1
|
||||
) * config.MENU_BOX_GAP
|
||||
x = (config.SCREEN_WIDTH - total_width) // 2
|
||||
|
||||
for index, difficulty in enumerate(self.options):
|
||||
rect = pygame.Rect(
|
||||
x, config.MENU_TOP, config.MENU_BOX_WIDTH, config.MENU_BOX_HEIGHT
|
||||
)
|
||||
active = index == self.index
|
||||
pygame.draw.rect(
|
||||
surface,
|
||||
config.COLOR_MENU_BOX_ACTIVE if active else config.COLOR_MENU_BOX,
|
||||
rect,
|
||||
border_radius=14,
|
||||
)
|
||||
pygame.draw.rect(
|
||||
surface,
|
||||
config.COLOR_WHITE if active else config.COLOR_MENU_BORDER,
|
||||
rect,
|
||||
width=config.MENU_BOX_BORDER,
|
||||
border_radius=14,
|
||||
)
|
||||
label = font.render(
|
||||
difficulty.label, True, (0, 0, 0) if active else config.COLOR_WHITE
|
||||
)
|
||||
surface.blit(
|
||||
label,
|
||||
(
|
||||
rect.centerx - label.get_width() // 2,
|
||||
rect.centery - label.get_height() // 2,
|
||||
),
|
||||
)
|
||||
x += config.MENU_BOX_WIDTH + config.MENU_BOX_GAP
|
||||
|
||||
hint = small.render(config.MENU_HINT, True, config.COLOR_WHITE)
|
||||
hint_y = config.MENU_TOP + config.MENU_BOX_HEIGHT + 60
|
||||
band = pygame.Rect(
|
||||
config.SCREEN_WIDTH // 2 - hint.get_width() // 2 - 30,
|
||||
hint_y - 10,
|
||||
hint.get_width() + 60,
|
||||
hint.get_height() + 20,
|
||||
)
|
||||
pygame.draw.rect(surface, config.COLOR_DIM, band, border_radius=10)
|
||||
surface.blit(hint, (band.x + 30, hint_y))
|
||||
@@ -1,47 +0,0 @@
|
||||
"""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."
|
||||
+41
-19
@@ -12,13 +12,22 @@ from .sequences import CaseSpec
|
||||
# --------------------------------------------------------------------------
|
||||
# Hintergrund-Bänder
|
||||
# --------------------------------------------------------------------------
|
||||
def draw_top_bar(surface: pygame.Surface, assets, percent: float) -> None:
|
||||
def draw_top_bar(surface: pygame.Surface, assets, percent: float, subtitle: str = "") -> 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))
|
||||
if subtitle:
|
||||
small = (assets.font_small or font).render(subtitle, True, config.COLOR_WHITE)
|
||||
surface.blit(
|
||||
small,
|
||||
(
|
||||
40 + title.get_width() + 48,
|
||||
config.TOP_BAR_HEIGHT // 2 - small.get_height() // 2,
|
||||
),
|
||||
)
|
||||
_draw_progress_bar(surface, assets, percent)
|
||||
|
||||
|
||||
@@ -56,11 +65,21 @@ def draw_background(surface: pygame.Surface, assets) -> None:
|
||||
)
|
||||
|
||||
|
||||
def draw_bottom_bar(surface: pygame.Surface, assets, upcoming: list[CaseSpec]) -> None:
|
||||
def clear_bottom_bar(surface: pygame.Surface) -> None:
|
||||
"""Untere Leiste schwärzen (ohne Inhalt)."""
|
||||
surface.fill(
|
||||
config.COLOR_BOTTOM_BAR,
|
||||
(0, config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT, config.SCREEN_WIDTH, config.BOTTOM_BAR_HEIGHT),
|
||||
(
|
||||
0,
|
||||
config.TOP_BAR_HEIGHT + config.GAME_AREA_HEIGHT,
|
||||
config.SCREEN_WIDTH,
|
||||
config.BOTTOM_BAR_HEIGHT,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def draw_bottom_bar(surface: pygame.Surface, assets, upcoming: list[CaseSpec]) -> None:
|
||||
clear_bottom_bar(surface)
|
||||
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))
|
||||
@@ -140,12 +159,19 @@ def _spec_size(spec: CaseSpec) -> tuple[int, int]:
|
||||
|
||||
|
||||
def draw_case(surface: pygame.Surface, assets, case: Case, angle: float = 0.0) -> None:
|
||||
"""Zeichnet einen Koffer – immer mit **seiner eigenen** Textur.
|
||||
|
||||
Ein gedrehter Koffer wird um 90° gedreht gezeichnet, bekommt aber keine
|
||||
andere Grafik.
|
||||
"""
|
||||
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
|
||||
image = assets.case_surface(*case.texture) if assets is not None else None
|
||||
if image is None:
|
||||
pygame.draw.rect(surface, case.color, (x, y, width, height))
|
||||
return
|
||||
if case.is_rotated:
|
||||
image = pygame.transform.rotate(image, -90)
|
||||
if angle:
|
||||
rotated = pygame.transform.rotate(image, angle)
|
||||
surface.blit(rotated, rotated.get_rect(bottomleft=(x, y + height)))
|
||||
@@ -171,23 +197,26 @@ def draw_hint(surface: pygame.Surface, assets, stack_possible: bool) -> None:
|
||||
)
|
||||
|
||||
|
||||
def draw_dim(surface: pygame.Surface, alpha: int = 170) -> None:
|
||||
"""Dunkelt das ganze Bild ab (für Menü und Ergebnis)."""
|
||||
overlay = pygame.Surface((config.SCREEN_WIDTH, config.SCREEN_HEIGHT))
|
||||
overlay.set_alpha(alpha)
|
||||
overlay.fill((0, 0, 0))
|
||||
surface.blit(overlay, (0, 0))
|
||||
|
||||
|
||||
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))
|
||||
"""Dunkler Schleier mit zentrierten Textzeilen (Ergebnis-Anzeige)."""
|
||||
draw_dim(surface, 220)
|
||||
|
||||
center_y = config.SCREEN_HEIGHT // 2
|
||||
line_gap = 16
|
||||
total_height = 0
|
||||
rendered: list[pygame.Surface] = []
|
||||
total_height = 0
|
||||
for line in lines:
|
||||
image = assets.font.render(line, True, config.COLOR_WHITE)
|
||||
rendered.append(image)
|
||||
@@ -196,10 +225,3 @@ def draw_overlay_lines(
|
||||
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),
|
||||
)
|
||||
@@ -1,50 +0,0 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user