"""Zustandsmaschine des Spiels: Koffer einlaufen lassen, stapeln, auswerten.""" from __future__ import annotations import enum import math import random from dataclasses import dataclass from . import config, scene from .case import Case from .difficulty import DEFAULT_DIFFICULTY, Difficulty from .input_map import Action from .sayings import saying_for from .sequences import CaseSpec, FALLBACK_SEQUENCE from .trailer import Trailer class State(enum.Enum): PLAYING = "playing" FINISHED = "finished" class HandleResult(enum.Enum): """Was der Aufrufer nach einer Aktion tun soll.""" CONTINUE = "continue" NEW_ROUND = "new_round" # zurück zur Anleitung/Stufenauswahl QUIT = "quit" # Programm beenden (Esc / B) @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, *, difficulty: Difficulty = DEFAULT_DIFFICULTY, rng: random.Random | None = None, previous_sequence: int | None = None, ) -> None: self.assets = assets self.rng = rng or random.Random() self.difficulty = difficulty self.state = State.PLAYING self.trailer = Trailer() source = sequences if sequences else [list(FALLBACK_SEQUENCE)] self.sequence_index = self._pick_sequence(len(source), previous_sequence) self.sequence: list[CaseSpec] = list(source[self.sequence_index]) self.index = 0 self.current: Case | None = None self.animation: Animation | None = None self.shake = 0.0 self.percent = 0.0 self.result_timer = 0.0 self.result_saying = "" self._spawn() # ------------------------------------------------------------------ # Sequenz / Nachschub # ------------------------------------------------------------------ def _pick_sequence(self, count: int, previous: int | None) -> int: """Zufällige Sequenz – aber nie zweimal direkt hintereinander dieselbe.""" if count <= 1: return 0 if previous is None or not 0 <= previous < count: return self.rng.randrange(count) choice = self.rng.randrange(count - 1) return choice if choice < previous else choice + 1 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) -> HandleResult: if action is Action.BACK: return HandleResult.QUIT if self.state is State.FINISHED: # Erst nach dem Cooldown darf die nächste Runde starten – sonst # springt man beim Weggehen versehentlich weiter. if action is Action.PRIMARY and self.result_timer <= 0: return HandleResult.NEW_ROUND return HandleResult.CONTINUE if self.animation is not None or self.current is None: return HandleResult.CONTINUE case = self.current if case.placed or not case.allow_snap: return HandleResult.CONTINUE 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 HandleResult.CONTINUE def _start_snap(self, case: Case) -> None: self._stop_roll() distance = max(1.0, case.x - self.trailer.wall_x) self.animation = Animation( kind="snap", duration=min(0.30, max(0.08, distance / 1200.0)), 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 State.FINISHED: self.result_timer = max(0.0, self.result_timer - dt) 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, self.difficulty) 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_WINDOW // 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.FINISHED: return self.state = State.FINISHED self._stop_roll() self.percent = self.trailer.load_percent() if self.percent >= 99.9: self.percent = 100.0 self.result_timer = config.RESULT_COOLDOWN self.result_saying = saying_for(self.percent, self.rng) # ------------------------------------------------------------------ # Zeichnen # ------------------------------------------------------------------ def draw(self, surface) -> None: assets = self.assets 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) 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.FINISHED: scene.draw_overlay_lines(surface, assets, self.result_lines()) def result_lines(self) -> list[str]: """Ergebniszeilen: Stufe+Prozent, Spruch, dann Cooldown bzw. Hinweis.""" lines = [ config.RESULT_TEMPLATE.format( difficulty=self.difficulty.label, percent=int(self.percent) ), self.result_saying, ] if self.result_timer > 0: lines.append( config.RESULT_COOLDOWN_TEMPLATE.format( seconds=math.ceil(self.result_timer) ) ) else: lines.append(config.RESULT_HINT) return lines # ------------------------------------------------------------------ # 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()