"""Zeichnet die Physikwelt: Hintergrund, Körper, Hervorhebungen, Vorschau und Debug-Ansicht.""" from __future__ import annotations import math from dataclasses import dataclass, field import pygame from sandbox.config import Color from sandbox.interaction import BodyStyle, ShapeKind from sandbox.physics import ZERO, Body, Circle, Vector2, World from sandbox.ui.drawing import draw_box, draw_circle from sandbox.ui.style import UiStyle, shade from sandbox.viewport import Viewport @dataclass(frozen=True) class SpawnPreview: """Halbtransparente Vorschau des Objekts, das ein Klick erzeugen würde. Attributes: shape: Form des Objekts. half_extents: Halbe Breite und Höhe in px. color: Farbe des Objekts. screen_center: Mittelpunkt auf dem Bildschirm (kann über der Sidebar liegen, wenn ein Objekt gerade aus der Vorschau-Kachel gezogen wird). """ shape: ShapeKind half_extents: tuple[float, float] color: Color screen_center: tuple[float, float] @dataclass class WorldOverlay: """Zustand der Bedienung, der über der Welt angezeigt wird. Attributes: hovered: Körper unter der Maus. held: Gezogener Körper. throw_velocity: Aktuelle Wurfgeschwindigkeit des gezogenen Körpers. paused: Ob die Simulation pausiert ist. debug: Ob die Debug-Ansicht (Kontakte, Schlafzustand) aktiv ist. debug_lines: Textzeilen der Debug-Ansicht. """ hovered: Body | None = None held: Body | None = None throw_velocity: Vector2 = ZERO paused: bool = False debug: bool = False debug_lines: list[str] = field(default_factory=list) def interpolate_angle(previous: float, current: float, alpha: float) -> float: """Interpoliert einen Winkel auf dem kürzeren Weg. Der Winkel wird in der Physik auf [−π, π] gehalten. Springt er von +3,1 auf −3,1, hat sich der Körper nur um 0,08 rad gedreht – naive Interpolation würde ihn für einen Frame fast eine ganze Umdrehung zurückdrehen lassen. """ return previous + math.remainder(current - previous, math.tau) * alpha class Renderer: """Zeichnet die Welt in ihren Bildschirmbereich. Args: screen: Zielfläche (das Fenster). viewport: Lage der Welt auf dem Bildschirm. style: Farben, Schriften, Maße. """ def __init__(self, screen: pygame.Surface, viewport: Viewport, style: UiStyle) -> None: self.screen = screen self.viewport = viewport self.style = style self._background = self._render_background() def _render_background(self) -> pygame.Surface: """Hintergrund mit Hilfsgitter – einmal vorberechnet statt jedes Bild neu.""" theme = self.style.theme spacing = self.style.ui.grid_spacing width, height = self.viewport.width, self.viewport.height surface = pygame.Surface((width, height)) surface.fill(theme.world_background) for x in range(spacing, width, spacing): pygame.draw.line(surface, theme.grid, (x, 0), (x, height)) for y in range(spacing, height, spacing): pygame.draw.line(surface, theme.grid, (0, y), (width, y)) return surface # ------------------------------------------------------------------ Welt def draw_world(self, world: World, alpha: float, overlay: WorldOverlay) -> None: """Zeichnet Hintergrund, alle Körper und die Bedien-Hinweise. Args: world: Die Welt. alpha: Interpolationsgewicht zwischen letztem und aktuellem Physikzustand. overlay: Hervorhebungen und Zusatzanzeigen. """ screen = self.screen area = pygame.Rect(self.viewport.rect) screen.blit(self._background, area.topleft) screen.set_clip(area) # Körper, die leicht in eine Wand ragen, nicht über die Sidebar for body in world.bodies: self._draw_body(body, alpha, overlay) if overlay.held is not None: self._draw_throw_arrow(overlay.held, overlay.throw_velocity) if overlay.debug: self._draw_debug(world, overlay.debug_lines) screen.set_clip(None) pygame.draw.rect(screen, self.style.theme.world_border, area, self.style.ui.border_width) if overlay.paused: self._draw_pause_badge() def _draw_body(self, body: Body, alpha: float, overlay: WorldOverlay) -> None: theme = self.style.theme ui = self.style.ui # Zwischen zwei Physikschritten interpolieren: Die Physik läuft mit 120 Hz, der # Bildschirm z. B. mit 60 Hz – ohne Interpolation ruckelt die Bewegung sichtbar. position = body.previous_position.lerp(body.position, alpha) center = self.viewport.to_screen(position) style = body.user_data color = style.color if isinstance(style, BodyStyle) else theme.body_palette[0] outline = shade(color, theme.outline_shade) if body.sleeping and overlay.debug: outline = theme.debug_sleeping if body is overlay.held: highlight: Color | None = theme.held_outline elif body is overlay.hovered: highlight = theme.highlight else: highlight = None shape = body.shape if isinstance(shape, Circle): angle = interpolate_angle(body.previous_angle, body.angle, alpha) draw_circle( self.screen, color, outline, center, shape.radius, outline_width=ui.outline_width, angle=angle, marker_length=ui.marker_length, ) if highlight is not None: pygame.draw.aacircle( self.screen, highlight, center, shape.radius, ui.highlight_width ) else: half_extents = shape.half_extents() draw_box( self.screen, color, outline, center, half_extents, outline_width=ui.outline_width ) if highlight is not None: draw_box( self.screen, color, highlight, center, half_extents, outline_width=ui.highlight_width, ) def _draw_throw_arrow(self, body: Body, velocity: Vector2) -> None: """Linie in Wurfrichtung; ihre Länge zeigt, wie weit der Körper in kurzer Zeit flöge.""" if velocity.length_squared() == 0.0: return ui = self.style.ui start = self.viewport.to_screen(body.position) end = ( start[0] + velocity.x * ui.throw_arrow_time, start[1] + velocity.y * ui.throw_arrow_time, ) color = self.style.theme.throw_arrow pygame.draw.line(self.screen, color, start, end, ui.outline_width) pygame.draw.aacircle(self.screen, color, end, ui.debug_contact_radius) def _draw_debug(self, world: World, lines: list[str]) -> None: """Kontaktpunkte, Kontaktnormalen und Kennzahlen.""" theme = self.style.theme ui = self.style.ui for contact in world.contacts: point = self.viewport.to_screen(contact.point) end = ( point[0] + contact.normal.x * ui.debug_normal_length, point[1] + contact.normal.y * ui.debug_normal_length, ) pygame.draw.line(self.screen, theme.debug_normal, point, end) pygame.draw.circle(self.screen, theme.debug_contact, point, ui.debug_contact_radius) x = self.viewport.left + ui.padding y = self.viewport.top + ui.padding for line in lines: rendered = self.style.render_text(line, self.style.small_font, theme.text) self.screen.blit(rendered, (x, y)) y += rendered.get_height() def _draw_pause_badge(self) -> None: theme = self.style.theme ui = self.style.ui text = self.style.render_text( "PAUSE – Leertaste zum Fortsetzen", self.style.label_font, theme.text ) badge = text.get_rect().inflate(2 * ui.padding, ui.padding) badge.midtop = (self.viewport.left + self.viewport.width // 2, ui.padding) pygame.draw.rect(self.screen, theme.panel_active, badge, border_radius=ui.corner_radius) self.screen.blit(text, text.get_rect(center=badge.center)) # ------------------------------------------------------------------ Vorschau def draw_preview(self, preview: SpawnPreview) -> None: """Zeichnet die halbtransparente Vorschau eines neuen Objekts.""" theme = self.style.theme ui = self.style.ui half_width, half_height = preview.half_extents margin = ui.highlight_width size = (math.ceil(2 * half_width) + 2 * margin, math.ceil(2 * half_height) + 2 * margin) layer = pygame.Surface(size, pygame.SRCALPHA) fill = (*preview.color, theme.preview_alpha) outline = (*theme.highlight, theme.preview_alpha) local_center = (size[0] / 2, size[1] / 2) if preview.shape is ShapeKind.CIRCLE: pygame.draw.aacircle(layer, fill, local_center, half_width) pygame.draw.aacircle(layer, outline, local_center, half_width, ui.outline_width) else: draw_box( layer, fill, outline, local_center, preview.half_extents, outline_width=ui.outline_width, ) self.screen.blit( layer, (preview.screen_center[0] - size[0] / 2, preview.screen_center[1] - size[1] / 2) )