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

+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()