Files
pygame-physics-opus5.5/src/sandbox/renderer.py
T
2026-09-24 15:45:31 +02:00

244 lines
9.6 KiB
Python
Raw 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.
"""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)
)