366 lines
12 KiB
Python
366 lines
12 KiB
Python
"""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()
|