proejct commit
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"""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)
|
||||
)
|
||||
Reference in New Issue
Block a user