Files

366 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()