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