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