proejct commit

This commit is contained in:
2026-09-24 15:45:31 +02:00
commit 82d52be8a5
63 changed files with 8748 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
"""Physik-Sandbox: interaktive 2D-Physiksimulation mit eigener Physik-Engine.
Aufbau:
* :mod:`sandbox.physics` – reine Python-Physik (nur ``math``), ohne pygame testbar.
* :mod:`sandbox.interaction` – Werkzeug-Logik (Erzeugen, Ziehen, Werfen), ebenfalls
pygame-frei.
* :mod:`sandbox.ui`, :mod:`sandbox.renderer`, :mod:`sandbox.input_handler`,
:mod:`sandbox.main` – Fenster, Zeichnen, Eingaben und Hauptschleife mit pygame-ce.
"""
import os
# pygame-ce gibt beim ersten Import eine Werbezeile aus. Das Paket wird immer vor pygame
# importiert, deshalb lässt sie sich hier zentral abschalten (ohne pygame zu importieren).
os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1")
__version__ = "1.0.0"
+5
View File
@@ -0,0 +1,5 @@
"""Ermöglicht den Start mit ``python -m sandbox``."""
from sandbox.main import main
raise SystemExit(main())
+440
View File
@@ -0,0 +1,440 @@
"""Zentrale Konfiguration der Physik-Sandbox.
Alle einstellbaren Zahlenwerte des Projekts stehen in diesem Modul, damit der übrige Code
frei von "Magic Numbers" bleibt. Zusammengehörige Werte sind in unveränderlichen
Dataclasses gebündelt; Tests variieren einzelne Parameter mit :func:`dataclasses.replace`.
Einheiten: Längen in Pixeln (px), Zeiten in Sekunden (s), Geschwindigkeiten in px/s.
Die y-Achse zeigt wie auf dem Bildschirm nach unten, die Schwerkraft wirkt also in +y.
Das Modul importiert ausschließlich die Standardbibliothek, weil auch die pygame-freie
Physik-Schicht darauf zugreift.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
# =============================================================================
# Grundgrößen der Simulation
# =============================================================================
#: Fester Physik-Zeitschritt in Sekunden (120 Hz), unabhängig von der Bildrate.
TIME_STEP: Final = 1.0 / 120.0
#: Kleinste und größte Halbausdehnung eines Körpers in px (Kreisradius bzw. halbe
#: Kantenlänge eines Rechtecks).
MIN_BODY_SIZE: Final = 12.0
MAX_BODY_SIZE: Final = 80.0
#: Sicherheitsabstand (< 1) zur theoretischen Tunnel-Grenze, siehe MAX_SPEED.
TUNNELING_SAFETY_FACTOR: Final = 0.9
#: Geschwindigkeitsobergrenze in px/s (≈ 1296 px/s).
#:
#: Warum: Die Kollisionserkennung ist diskret, sie sieht nur die Positionen am Ende jedes
#: Schritts. Fliegen zwei der kleinsten Körper frontal aufeinander zu, darf ihre
#: Relativbewegung pro Schritt (2·v·dt) die Summe ihrer Radien (2·MIN_BODY_SIZE) nicht
#: überschreiten – sonst springen die Mittelpunkte aneinander vorbei ("Tunneln").
#: Daraus folgt v < MIN_BODY_SIZE / dt. Die Wände sind Halbebenen und damit ohnehin
#: tunnelsicher: Ein Körper "hinter" der Wand wird immer als Durchdringung erkannt.
MAX_SPEED: Final = TUNNELING_SAFETY_FACTOR * MIN_BODY_SIZE / TIME_STEP
#: Obergrenze der Winkelgeschwindigkeit in rad/s: So schnell dreht sich der kleinste Kreis,
#: wenn er mit MAX_SPEED rollt (ω = v / r). Verhindert numerisch explodierende Drehungen.
MAX_ANGULAR_SPEED: Final = MAX_SPEED / MIN_BODY_SIZE
#: Toleranz für Gleitkomma-Vergleiche, z. B. "Vektor hat Länge 0".
NUMERIC_EPSILON: Final = 1e-9
#: Umrechnung Sekunden → Millisekunden (pygame misst Frame-Zeiten in ms).
MILLISECONDS_PER_SECOND: Final = 1000.0
#: Standard-Materialwerte für neue Körper.
DEFAULT_DENSITY: Final = 1.0
DEFAULT_RESTITUTION: Final = 0.5
DEFAULT_FRICTION: Final = 0.5
@dataclass(frozen=True)
class PhysicsConfig:
"""Parameter der Physik-Engine.
Attributes:
gravity: Schwerkraftbeschleunigung (x, y) in px/s². 980 px/s² entspricht 9,8 m/s²
bei einem Maßstab von 100 px pro Meter.
time_step: Fester Zeitschritt in s.
velocity_iterations: Durchläufe des Impuls-Solvers pro Schritt. Mehr Durchläufe
lassen Stapel steifer werden, kosten aber Rechenzeit.
position_iterations: Durchläufe der Positionskorrektur pro Schritt.
restitution_threshold: Minimale Aufprallgeschwindigkeit in px/s, ab der Körper
abprallen. Darunter wird der Stoß als unelastisch behandelt, damit aufliegende
Körper nicht durch winzige Dauer-Hüpfer zittern.
penetration_slop: Geduldete Eindringtiefe in px. Wird nicht korrigiert, damit der
Kontakt zu einer Unterlage im nächsten Schritt sicher wieder erkannt wird –
sonst wechselten "Kontakt" und "kein Kontakt" ständig ab (Zittern).
position_correction_factor: Anteil der Eindringtiefe, der pro Durchlauf
korrigiert wird. Werte < 1 verteilen die Korrektur weich auf mehrere Schritte.
max_position_correction: Obergrenze der Korrektur pro Kontakt und Durchlauf in px.
Stark überlappende Körper (z. B. frisch erzeugt) werden so sanft getrennt.
max_speed: Geschwindigkeitsobergrenze in px/s (siehe MAX_SPEED).
max_angular_speed: Obergrenze der Winkelgeschwindigkeit in rad/s.
linear_damping: Lineare Dämpfung (Luftwiderstand) in 1/s.
angular_damping: Dämpfung der Drehung (Luftwiderstand) in 1/s.
rolling_resistance: Rollwiderstandsbeiwert μ_r (dimensionslos). An jedem Kontakt
bremst ein Moment von höchstens μ_r · Normalkraft · Radius die Drehung. Ohne
Rollwiderstand würde ein rollender Kreis nie anhalten, weil reines Rollen keine
Reibungsenergie verbraucht; ein Rechteck auf einem Kreis führe darauf wie auf
einer Rolle davon.
warm_starting: Startet den Solver mit den Impulsen des vorigen Schritts.
warm_start_min_alignment: Minimales Skalarprodukt zwischen alter und neuer
Kontaktnormale, damit der alte Impuls wiederverwendet wird. Dreht sich die
Normale stärker, passt der alte Impuls nicht mehr.
sleep_enabled: Schaltet den Schlafmodus für ruhende Körper ein.
sleep_linear_speed: Unterhalb dieser Geschwindigkeit (px/s) gilt ein Körper als
ruhend.
sleep_angular_speed: Unterhalb dieser Winkelgeschwindigkeit (rad/s) gilt ein Körper
als ruhend.
time_to_sleep: So lange (s) muss eine ganze Kontaktgruppe ruhen, bevor sie einschläft.
max_wall_penetration: Notbremse in px: Weiter darf ein Körper nie in eine Wand
ragen. Der Wert liegt über der größten Eindringtiefe, die ein frei fliegender
Körper in einem Schritt erreichen kann (MAX_SPEED · dt ≈ 10,8 px). Die Grenze
greift deshalb nie bei normalen Aufprallen – die löst der Solver –, sondern nur,
wenn ein Körper z. B. von einem gezogenen Objekt gegen die Wand gequetscht wird.
wall_restitution: Stoßzahl der Wände. 0 bedeutet: Beim Mischen mit max(e_A, e_B)
bestimmt allein das Material des Körpers, wie stark er abprallt.
wall_friction: Reibungskoeffizient der Wände.
"""
gravity: tuple[float, float] = (0.0, 980.0)
time_step: float = TIME_STEP
velocity_iterations: int = 8
position_iterations: int = 2
restitution_threshold: float = 50.0
penetration_slop: float = 0.5
position_correction_factor: float = 0.4
max_position_correction: float = 6.0
max_speed: float = MAX_SPEED
max_angular_speed: float = MAX_ANGULAR_SPEED
linear_damping: float = 0.0
angular_damping: float = 0.0
rolling_resistance: float = 0.1
warm_starting: bool = True
warm_start_min_alignment: float = 0.95
sleep_enabled: bool = True
sleep_linear_speed: float = 4.0
sleep_angular_speed: float = 0.1
time_to_sleep: float = 0.5
max_wall_penetration: float = MIN_BODY_SIZE
wall_restitution: float = 0.0
wall_friction: float = 0.6
#: Standardkonfiguration der Physik.
PHYSICS: Final = PhysicsConfig()
# =============================================================================
# Fenster und Hauptschleife
# =============================================================================
@dataclass(frozen=True)
class WindowConfig:
"""Fenster und Hauptschleife.
Attributes:
width: Fensterbreite in px.
height: Fensterhöhe in px.
title: Fenstertitel.
sidebar_width: Breite der Werkzeugleiste am linken Rand in px. Rechts davon
beginnt die Physikwelt.
fps_limit: Obergrenze der Bildrate. 60 Bilder/s genügen für flüssige Bewegung; die
Physik läuft unabhängig davon mit 120 Hz.
max_steps_per_frame: Höchstens so viele Physikschritte pro Bild (Schutz vor der
"Spiral of Death", siehe :class:`~sandbox.physics.timestep.FixedTimestep`).
stats_smoothing: Glättungsfaktor (0..1) der FPS-/Rechenzeit-Anzeige pro Bild.
screenshot_delay: Standard-Laufzeit (s) vor einem Bildschirmfoto mit
``--screenshot``.
"""
width: int = 1280
height: int = 720
title: str = "Physik-Sandbox"
sidebar_width: int = 260
fps_limit: int = 60
max_steps_per_frame: int = 8
stats_smoothing: float = 0.1
screenshot_delay: float = 3.0
# =============================================================================
# Werkzeuge und Interaktion
# =============================================================================
@dataclass(frozen=True)
class InteractionConfig:
"""Erzeugen, Ziehen und Werfen von Körpern.
Attributes:
drag_response_rate: Wie schnell ein gezogener Körper der Maus folgt (1/s). Pro
Physikschritt wird der Anteil ``rate·dt`` des Abstands zum Ziel überbrückt – bei
60/s und dt = 1/120 s also die Hälfte. Werte > 1/dt würden überschwingen.
throw_window: Zeitfenster (s) der Mausbewegung, aus dem die Wurfgeschwindigkeit
gemittelt wird ("die letzten Frames").
throw_smoothing_time: Zeitkonstante (s) der zusätzlichen exponentiellen Glättung.
max_throw_speed: Obergrenze der Wurfgeschwindigkeit in px/s.
pick_tolerance: Zusätzlicher Rand (px), mit dem kleine Körper leichter zu greifen
sind.
max_bodies: Höchstzahl gleichzeitiger Körper (schützt die Bildrate).
size_wheel_step: Größenänderung (px) pro Mausrad-Raster.
min_size: Kleinste wählbare Größe (Radius bzw. halbe Kantenlänge) in px.
max_size: Größte wählbare Größe in px.
size_step: Rasterung des Größenreglers in px.
min_aspect_ratio: Kleinstes Seitenverhältnis Breite:Höhe eines Rechtecks.
max_aspect_ratio: Größtes Seitenverhältnis Breite:Höhe eines Rechtecks.
aspect_ratio_step: Rasterung des Seitenverhältnis-Reglers.
min_density: Kleinste wählbare Dichte.
max_density: Größte wählbare Dichte.
density_step: Rasterung des Dichte-Reglers.
max_restitution: Größte wählbare Elastizität.
restitution_step: Rasterung des Elastizitäts-Reglers.
default_size: Startwert der Größe in px.
default_aspect_ratio: Startwert des Seitenverhältnisses.
default_density: Startwert der Dichte.
default_restitution: Startwert der Elastizität.
friction: Reibungskoeffizient neu erzeugter Körper.
"""
drag_response_rate: float = 60.0
throw_window: float = 0.1
throw_smoothing_time: float = 0.05
max_throw_speed: float = MAX_SPEED
pick_tolerance: float = 4.0
max_bodies: int = 300
size_wheel_step: float = 2.0
min_size: float = MIN_BODY_SIZE
max_size: float = MAX_BODY_SIZE
size_step: float = 1.0
min_aspect_ratio: float = 0.5
max_aspect_ratio: float = 2.0
aspect_ratio_step: float = 0.05
min_density: float = 0.2
max_density: float = 5.0
density_step: float = 0.1
max_restitution: float = 1.0
restitution_step: float = 0.05
default_size: float = 28.0
default_aspect_ratio: float = 1.5
default_density: float = DEFAULT_DENSITY
default_restitution: float = DEFAULT_RESTITUTION
friction: float = DEFAULT_FRICTION
#: Standardkonfiguration der Interaktion.
INTERACTION: Final = InteractionConfig()
# =============================================================================
# Darstellung
# =============================================================================
#: RGB-Farbe.
Color = tuple[int, int, int]
@dataclass(frozen=True)
class Theme:
"""Farben der Oberfläche (dunkles Farbschema).
Attributes:
world_background: Hintergrund der Physikwelt.
grid: Hilfsgitter der Welt.
world_border: Rahmen um die Welt.
sidebar_background: Hintergrund der Werkzeugleiste.
panel: Grundfarbe von Buttons und Reglern.
panel_hover: Button unter der Maus.
panel_active: Aktiver Button bzw. gewählte Option.
accent: Akzentfarbe (Reglerfüllung, aktive Umrandung).
text: Normaler Text.
text_dim: Nebensächlicher Text.
danger: Farbe für "Alles löschen".
highlight: Umrandung des Körpers unter der Maus.
held_outline: Umrandung des gezogenen Körpers.
throw_arrow: Pfeil der aktuellen Wurfgeschwindigkeit.
preview_alpha: Deckkraft (0–255) der Vorschau beim Erzeugen.
outline_shade: Faktor, um den die Umrandung dunkler ist als die Füllung.
debug_contact: Kontaktpunkte in der Debug-Ansicht.
debug_normal: Kontaktnormalen in der Debug-Ansicht.
debug_sleeping: Umrandung schlafender Körper in der Debug-Ansicht.
body_palette: Auswahl an Körperfarben.
"""
world_background: Color = (22, 25, 32)
grid: Color = (32, 36, 46)
world_border: Color = (64, 71, 88)
sidebar_background: Color = (15, 17, 22)
panel: Color = (31, 35, 45)
panel_hover: Color = (42, 47, 60)
panel_active: Color = (48, 72, 102)
accent: Color = (96, 176, 240)
text: Color = (226, 230, 239)
text_dim: Color = (136, 145, 164)
danger: Color = (228, 92, 92)
highlight: Color = (255, 255, 255)
held_outline: Color = (255, 214, 102)
throw_arrow: Color = (255, 214, 102)
preview_alpha: int = 110
outline_shade: float = 0.6
debug_contact: Color = (255, 214, 10)
debug_normal: Color = (255, 110, 64)
debug_sleeping: Color = (120, 130, 150)
body_palette: tuple[Color, ...] = (
(239, 83, 80),
(255, 167, 38),
(255, 213, 79),
(102, 187, 106),
(38, 198, 218),
(66, 133, 244),
(171, 71, 188),
(236, 64, 122),
)
@dataclass(frozen=True)
class UiConfig:
"""Schriften, Abstände und Maße der Oberfläche.
Attributes:
font_names: Bevorzugte Systemschriften (die erste vorhandene wird genutzt).
title_font_size: Schriftgröße des Titels.
label_font_size: Schriftgröße von Beschriftungen.
small_font_size: Schriftgröße von Hinweisen und Werten.
padding: Innenabstand der Sidebar in px.
section_gap: Abstand zwischen Abschnitten in px.
row_gap: Abstand zwischen Elementen eines Abschnitts in px.
header_height: Höhe einer Abschnittsüberschrift in px.
button_height: Höhe von Buttons in px.
slider_height: Höhe eines Reglers (Beschriftung + Schiene) in px.
slider_track_height: Dicke der Reglerschiene in px.
slider_knob_radius: Radius des Reglerknopfs in px.
swatch_size: Kantenlänge eines Farbfelds in px.
swatch_gap: Abstand zwischen Farbfeldern in px.
spawn_tile_height: Höhe der Vorschau-Kachel in px.
corner_radius: Eckenradius von Buttons und Kacheln in px.
border_width: Linienstärke von Rahmen (Buttons, Umschalter, Weltrand) in px.
selection_ring_gap: Abstand des Auswahlrings um das gewählte Farbfeld in px.
selection_ring_width: Linienstärke des Auswahlrings in px.
grid_spacing: Abstand der Gitterlinien der Welt in px.
outline_width: Linienstärke von Körperumrandungen in px.
highlight_width: Linienstärke der Hervorhebung in px.
throw_arrow_time: Länge des Wurfpfeils als Flugzeit in s (Pfeil = v · Zeit).
debug_normal_length: Länge der gezeichneten Kontaktnormalen in px.
debug_contact_radius: Radius der gezeichneten Kontaktpunkte in px.
preview_scale_limit: Höchster Anteil der Kachelhöhe, den die Vorschau einnimmt.
marker_length: Länge des Drehmarkers eines Kreises als Anteil des Radius.
text_cache_size: So viele gerenderte Texte werden höchstens zwischengespeichert.
"""
font_names: tuple[str, ...] = ("segoeui", "helveticaneue", "dejavusans", "arial")
title_font_size: int = 22
label_font_size: int = 15
small_font_size: int = 13
padding: int = 16
section_gap: int = 12
row_gap: int = 6
header_height: int = 20
button_height: int = 32
slider_height: int = 38
slider_track_height: int = 4
slider_knob_radius: int = 7
swatch_size: int = 22
swatch_gap: int = 3
spawn_tile_height: int = 92
corner_radius: int = 6
border_width: int = 1
selection_ring_gap: int = 2
selection_ring_width: int = 2
grid_spacing: int = 40
outline_width: int = 2
highlight_width: int = 3
throw_arrow_time: float = 0.15
debug_normal_length: float = 18.0
debug_contact_radius: int = 3
preview_scale_limit: float = 0.8
marker_length: float = 0.85
text_cache_size: int = 512
#: Tastenbelegung: Tastenname (wie in ``pygame.key.key_code``) → Aktion.
KEY_BINDINGS: Final = (
("space", "toggle_pause"),
("g", "toggle_gravity"),
("c", "clear"),
("delete", "delete_hovered"),
("1", "shape_circle"),
("2", "shape_box"),
("f1", "toggle_debug"),
("escape", "quit"),
)
@dataclass(frozen=True)
class DemoSceneConfig:
"""Aufbau der Beispielszene (Start mit ``--demo``).
x- und y-Positionen sind Anteile der Weltbreite bzw. -höhe, damit die Szene in jede
Fenstergröße passt.
Attributes:
tower_x: Position des Rechteckturms.
tower_levels: Anzahl der Rechtecke im Turm.
box_size: Größe der Turm-Rechtecke in px.
box_aspect_ratio: Seitenverhältnis der Turm-Rechtecke.
pyramid_x: Mitte der Kreispyramide.
pyramid_rows: Anzahl der Reihen der Pyramide.
pyramid_circle_size: Radius der Pyramidenkreise in px.
pyramid_density: Dichte der Pyramidenkreise (schwer, damit sie stabil liegen).
pyramid_row_height: Vertikaler Reihenabstand in Radien. Dichteste Packung wäre
√3 ≈ 1,732; etwas mehr lässt einen hauchdünnen Spalt, damit nichts überlappt.
balls_x: Position des ersten Gummiballs.
balls_y: Starthöhe der Gummibälle.
ball_count: Anzahl der Gummibälle.
ball_size: Radius der Gummibälle in px.
ball_spacing: Abstand der Bälle in Radien.
ball_restitution: Elastizität der Gummibälle.
"""
tower_x: float = 0.78
tower_levels: int = 7
box_size: float = 22.0
box_aspect_ratio: float = 2.0
pyramid_x: float = 0.45
pyramid_rows: int = 5
pyramid_circle_size: float = 20.0
pyramid_density: float = 3.0
pyramid_row_height: float = 1.75
balls_x: float = 0.15
balls_y: float = 0.2
ball_count: int = 4
ball_size: float = 16.0
ball_spacing: float = 2.4
ball_restitution: float = 0.8
@dataclass(frozen=True)
class AppConfig:
"""Gesamtkonfiguration der Anwendung."""
window: WindowConfig = WindowConfig()
physics: PhysicsConfig = PhysicsConfig()
interaction: InteractionConfig = InteractionConfig()
theme: Theme = Theme()
ui: UiConfig = UiConfig()
demo: DemoSceneConfig = DemoSceneConfig()
#: Standardkonfiguration der Anwendung.
APP: Final = AppConfig()
+51
View File
@@ -0,0 +1,51 @@
"""Beispielszene zum Ausprobieren (Start mit ``python -m sandbox --demo``)."""
from __future__ import annotations
from dataclasses import replace
from sandbox.config import DemoSceneConfig
from sandbox.interaction import BodyFactory, ShapeKind, ToolSettings
from sandbox.physics import Vector2
def build_demo_scene(factory: BodyFactory, base: ToolSettings, scene: DemoSceneConfig) -> None:
"""Baut einen Turm aus Rechtecken, eine Kreispyramide und ein paar Gummibälle auf.
Args:
factory: Erzeugt die Körper (und fügt sie der Welt hinzu).
base: Ausgangseinstellungen (Farbe, Material), die je Gruppe abgewandelt werden.
scene: Aufbau der Szene.
"""
world = factory.world
floor = world.height
boxes = replace(
base, shape=ShapeKind.BOX, size=scene.box_size, aspect_ratio=scene.box_aspect_ratio
)
_, half_height = boxes.half_extents()
for level in range(scene.tower_levels):
y = floor - half_height - level * 2 * half_height
factory.create(boxes, Vector2(world.width * scene.tower_x, y))
circles = replace(
base,
shape=ShapeKind.CIRCLE,
size=scene.pyramid_circle_size,
density=scene.pyramid_density,
)
radius = circles.size
rows = scene.pyramid_rows
for row in range(rows):
for column in range(rows - row):
# Jede Reihe ist um einen Radius eingerückt und liegt in den Mulden der unteren.
x = world.width * scene.pyramid_x + (2 * column + row - rows + 1) * radius
y = floor - radius - row * radius * scene.pyramid_row_height
factory.create(circles, Vector2(x, y))
balls = replace(
base, shape=ShapeKind.CIRCLE, size=scene.ball_size, restitution=scene.ball_restitution
)
for index in range(scene.ball_count):
x = world.width * scene.balls_x + index * scene.ball_spacing * scene.ball_size
factory.create(balls, Vector2(x, world.height * scene.balls_y))
+232
View File
@@ -0,0 +1,232 @@
"""Übersetzt pygame-Eingaben in Aktionen von Sidebar, Welt und Werkzeugen.
Maus-Events werden zuerst der Sidebar angeboten. Was sie nicht verbraucht und in der Welt
liegt, wird zur Werkzeug-Aktion: Linksklick auf einen Körper greift ihn, Linksklick ins
Leere erzeugt einen neuen Körper (und greift ihn sofort, sodass man ihn direkt werfen
kann), Rechtsklick löscht.
"""
from __future__ import annotations
import contextlib
from collections.abc import Callable
from functools import partial
from typing import Protocol
import pygame
from sandbox.config import KEY_BINDINGS, InteractionConfig
from sandbox.interaction import BodyFactory, DragController, ShapeKind, ToolSettings
from sandbox.physics import Body, Vector2, World
from sandbox.renderer import SpawnPreview
from sandbox.ui.sidebar import Sidebar, SidebarControls
from sandbox.viewport import Viewport
class AppControls(SidebarControls, Protocol):
"""Aktionen der Anwendung, die per Tastatur ausgelöst werden können."""
def request_quit(self) -> None:
"""Beendet die Anwendung nach dem aktuellen Frame."""
...
def toggle_debug(self) -> None:
"""Schaltet die Debug-Ansicht um."""
...
class InputHandler:
"""Verteilt Maus- und Tastatur-Events.
Args:
world: Die Physikwelt.
viewport: Lage der Welt auf dem Bildschirm.
drag: Steuerung für Greifen und Werfen.
factory: Erzeugt neue Körper.
tools: Aktuelle Werkzeug-Einstellungen.
config: Interaktionsparameter.
controls: Aktionen der Anwendung.
"""
def __init__(
self,
*,
world: World,
viewport: Viewport,
drag: DragController,
factory: BodyFactory,
tools: ToolSettings,
config: InteractionConfig,
controls: AppControls,
) -> None:
self.world = world
self.viewport = viewport
self.drag = drag
self.factory = factory
self.tools = tools
self.config = config
self.controls = controls
self.sidebar: Sidebar | None = None
self.mouse_pos: tuple[int, int] = pygame.mouse.get_pos()
self.hovered_body: Body | None = None
self.spawn_drag_active = False
self._cursor: int | None = None
actions: dict[str, Callable[[], None]] = {
"toggle_pause": controls.toggle_pause,
"toggle_gravity": controls.toggle_gravity,
"clear": controls.clear_world,
"delete_hovered": self.delete_hovered,
"shape_circle": partial(setattr, tools, "shape", ShapeKind.CIRCLE),
"shape_box": partial(setattr, tools, "shape", ShapeKind.BOX),
"toggle_debug": controls.toggle_debug,
"quit": controls.request_quit,
}
self._key_actions = {
pygame.key.key_code(key): actions[action] for key, action in KEY_BINDINGS
}
# ------------------------------------------------------------------ Abfragen
@property
def pointer_in_world(self) -> bool:
"""Ob die Maus über der Welt steht."""
return self.viewport.contains(self.mouse_pos)
@property
def world_pointer(self) -> Vector2:
"""Mausposition in Weltkoordinaten."""
return self.viewport.to_world(self.mouse_pos)
def spawn_preview(self) -> SpawnPreview | None:
"""Vorschau des Objekts, das ein Klick an der aktuellen Mausposition erzeugen würde."""
half_extents = self.tools.half_extents()
if self.spawn_drag_active:
center = (float(self.mouse_pos[0]), float(self.mouse_pos[1]))
elif (
self.pointer_in_world
and self.drag.body is None
and self.hovered_body is None
and not self.factory.is_full
):
clamped = self.world.clamp_inside(self.world_pointer, half_extents)
center = self.viewport.to_screen(clamped)
else:
return None
color = self.factory.color_for(self.tools)
return SpawnPreview(self.tools.shape, half_extents, color, center)
# ------------------------------------------------------------------ Aktionen
def begin_spawn_drag(self) -> None:
"""Beginnt das Herausziehen eines neuen Objekts aus der Sidebar."""
self.spawn_drag_active = True
def delete_hovered(self) -> None:
"""Löscht den Körper unter der Maus."""
body = self._body_under_pointer()
if body is not None:
self.delete(body)
def delete(self, body: Body) -> None:
"""Entfernt einen Körper (und lässt ihn vorher los, falls er gehalten wird)."""
if self.drag.body is body:
self.drag.cancel()
if self.hovered_body is body:
self.hovered_body = None
self.world.remove_body(body)
def forget_bodies(self) -> None:
"""Vergisst Verweise auf Körper (nach "Alles löschen")."""
self.drag.cancel()
self.hovered_body = None
self.spawn_drag_active = False
# ------------------------------------------------------------------ Events
def handle_event(self, event: pygame.event.Event, time: float) -> None:
"""Verarbeitet ein pygame-Event.
Args:
event: Das Event.
time: Aktuelle Anwendungszeit in s (für die Wurfgeschwindigkeit).
"""
if event.type == pygame.QUIT:
self.controls.request_quit()
elif event.type == pygame.KEYDOWN:
action = self._key_actions.get(event.key)
if action is not None:
action()
elif event.type == pygame.MOUSEBUTTONDOWN:
self.mouse_pos = event.pos
if not self._offer_to_sidebar(event):
self._press_in_world(event.button, time)
elif event.type == pygame.MOUSEMOTION:
self.mouse_pos = event.pos
if not self._offer_to_sidebar(event):
self._move(time)
elif event.type == pygame.MOUSEBUTTONUP:
self.mouse_pos = event.pos
if event.button == pygame.BUTTON_LEFT:
# Loslassen beendet ein Ziehen immer – auch über der Sidebar.
self.drag.release(paused=self.controls.is_paused())
self.spawn_drag_active = False
self._offer_to_sidebar(event)
elif event.type == pygame.MOUSEWHEEL and not self._offer_to_sidebar(event):
self._change_size(event.y)
def update(self, time: float) -> None:
"""Einmal pro Frame: Mausposition an den Wurf-Tracker melden, Hover bestimmen.
Die Position wird auch ohne Maus-Event gemeldet: Steht die Maus still, kommen keine
Events – der Tracker muss aber merken, dass die Geschwindigkeit auf 0 fällt.
"""
if self.drag.body is not None:
self.drag.move(self.world_pointer, time)
self.hovered_body = None if self.drag.body is not None else self._body_under_pointer()
grabbing = self.drag.body is not None or self.hovered_body is not None
cursor = pygame.SYSTEM_CURSOR_HAND if grabbing else pygame.SYSTEM_CURSOR_ARROW
if cursor != self._cursor:
self._cursor = cursor
# Im Headless-Modus (Tests) gibt es keinen echten Mauszeiger.
with contextlib.suppress(pygame.error):
pygame.mouse.set_cursor(cursor)
def _offer_to_sidebar(self, event: pygame.event.Event) -> bool:
"""Bietet ein Event der Sidebar an; True, wenn sie es verbraucht hat."""
if self.sidebar is None:
return False
return self.sidebar.handle_event(event, self.mouse_pos)
def _press_in_world(self, button: int, time: float) -> None:
if not self.pointer_in_world:
return
pointer = self.world_pointer
if button == pygame.BUTTON_LEFT:
body = self._body_under_pointer() or self.factory.create(self.tools, pointer)
if body is not None:
self.drag.grab(body, pointer, time)
elif button == pygame.BUTTON_RIGHT:
body = self._body_under_pointer()
if body is not None:
self.delete(body)
def _move(self, time: float) -> None:
if self.spawn_drag_active and self.pointer_in_world:
# Aus der Sidebar gezogenes Objekt erscheint, sobald die Maus die Welt erreicht,
# und hängt ab dann wie ein gegriffenes Objekt an der Maus.
self.spawn_drag_active = False
body = self.factory.create(self.tools, self.world_pointer)
if body is not None:
self.drag.grab(body, self.world_pointer, time)
if self.drag.body is not None:
self.drag.move(self.world_pointer, time)
def _change_size(self, wheel_steps: int) -> None:
config = self.config
size = self.tools.size + wheel_steps * config.size_wheel_step
self.tools.size = min(max(size, config.min_size), config.max_size)
def _body_under_pointer(self) -> Body | None:
if not self.pointer_in_world:
return None
return self.world.body_at(self.world_pointer, self.config.pick_tolerance)
+20
View File
@@ -0,0 +1,20 @@
"""Werkzeug-Logik der Sandbox: Körper erzeugen, greifen, ziehen und werfen.
Die Klassen hier arbeiten ausschließlich mit Weltkoordinaten und Zeitstempeln, nicht mit
pygame-Events. Dadurch lässt sich das Verhalten (z. B. die Glättung der
Wurfgeschwindigkeit) ohne Fenster testen; die Übersetzung von Maus-Events übernimmt
:mod:`sandbox.input_handler`.
"""
from sandbox.interaction.drag import DragController
from sandbox.interaction.tools import BodyFactory, BodyStyle, ShapeKind, ToolSettings
from sandbox.interaction.velocity_tracker import VelocityTracker
__all__ = [
"BodyFactory",
"BodyStyle",
"DragController",
"ShapeKind",
"ToolSettings",
"VelocityTracker",
]
+116
View File
@@ -0,0 +1,116 @@
"""Greifen, Ziehen und Werfen eines Körpers mit der Maus."""
from __future__ import annotations
from sandbox.config import InteractionConfig
from sandbox.interaction.velocity_tracker import VelocityTracker
from sandbox.physics import ZERO, Body, Vector2, World
class DragController:
"""Führt einen gegriffenen Körper zur Maus, ohne die Physik auszuhebeln.
Der Körper wird *nicht* einfach an die Mausposition gesetzt – dann könnte er durch
andere Körper und Wände hindurchgeschoben werden. Stattdessen bekommt er vor jedem
Physikschritt die Geschwindigkeit, die ihn in Richtung Ziel bringt::
v = (Ziel − Position) · rate
Danach löst der Solver ganz normal die Kontakte: Leichte Körper werden weggeschoben,
Wände und schwere Stapel bremsen den gezogenen Körper. Das gezogene Objekt kollidiert
also weiterhin. Beim Loslassen erhält es die geglättete Mausgeschwindigkeit
(:class:`~sandbox.interaction.velocity_tracker.VelocityTracker`) – das ist der Wurf.
Args:
world: Die Welt, in der gezogen wird.
config: Folgegeschwindigkeit, Wurf-Glättung und -Obergrenze.
"""
def __init__(self, world: World, config: InteractionConfig) -> None:
self.world = world
self.config = config
self.tracker = VelocityTracker(config.throw_window, config.throw_smoothing_time)
self._body: Body | None = None
self._offset = ZERO
self._target = ZERO
@property
def body(self) -> Body | None:
"""Der aktuell gehaltene Körper (oder ``None``)."""
return self._body
@property
def target(self) -> Vector2:
"""Zielposition des Schwerpunkts (Mausposition plus Griffversatz)."""
return self._target
@property
def throw_velocity(self) -> Vector2:
"""Geschwindigkeit, die der Körper beim Loslassen erhielte (px/s)."""
return self.tracker.velocity.clamped(self.config.max_throw_speed)
def grab(self, body: Body, pointer: Vector2, time: float) -> None:
"""Greift einen Körper an der Stelle ``pointer``.
Der Versatz zwischen Griffpunkt und Schwerpunkt bleibt beim Ziehen erhalten, damit
der Körper nicht mit seinem Mittelpunkt zur Maus springt.
"""
self.cancel()
self._body = body
body.held = True
body.wake()
self._offset = body.position - pointer
self._target = body.position
self.tracker.reset(time, pointer)
def move(self, pointer: Vector2, time: float) -> None:
"""Meldet die aktuelle Mausposition (bei jedem Event und einmal pro Frame)."""
body = self._body
if body is None:
return
self.tracker.add_sample(time, pointer)
# Das Ziel so klemmen, dass der Körper ganz in die Welt passt; sonst würde er
# dauerhaft gegen die Wand gedrückt.
self._target = self.world.clamp_inside(pointer + self._offset, body.half_extents())
def apply(self, dt: float) -> None:
"""Setzt vor einem Physikschritt die Geschwindigkeit Richtung Ziel."""
body = self._body
if body is None:
return
# rate·dt ≤ 1, sonst würde der Körper über das Ziel hinausschießen und pendeln.
rate = min(self.config.drag_response_rate, 1.0 / dt)
velocity = (self._target - body.position) * rate
body.velocity = velocity.clamped(self.world.config.max_speed)
body.wake()
def place(self) -> None:
"""Setzt den Körper direkt aufs Ziel – nur im Pausenmodus, wenn keine Physik läuft."""
body = self._body
if body is None:
return
body.teleport(self._target)
body.velocity = ZERO
def release(self, *, paused: bool) -> Body | None:
"""Lässt den Körper los und wirft ihn mit der geglätteten Mausgeschwindigkeit.
Args:
paused: Im Pausenmodus wird nicht geworfen; der Körper bleibt liegen.
Returns:
Den losgelassenen Körper (oder ``None``, wenn keiner gehalten wurde).
"""
body = self._body
if body is None:
return None
body.velocity = ZERO if paused else self.throw_velocity
self.cancel()
return body
def cancel(self) -> None:
"""Beendet das Halten, ohne die Geschwindigkeit zu ändern (z. B. beim Löschen)."""
if self._body is not None:
self._body.held = False
self._body.wake()
self._body = None
+137
View File
@@ -0,0 +1,137 @@
"""Werkzeug-Einstellungen der Sidebar und das Erzeugen neuer Körper."""
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from enum import Enum
from sandbox.config import MIN_BODY_SIZE, Color, InteractionConfig
from sandbox.physics import Body, Material, Vector2, World
class ShapeKind(Enum):
"""Form neu erzeugter Körper."""
CIRCLE = "circle"
BOX = "box"
@dataclass
class ToolSettings:
"""Aktuelle Auswahl in der Werkzeugleiste.
Attributes:
shape: Form neuer Körper.
size: Radius bzw. "mittlere" halbe Kantenlänge in px.
aspect_ratio: Seitenverhältnis Breite:Höhe (nur für Rechtecke).
density: Dichte (Masse = Fläche × Dichte).
restitution: Elastizität (Stoßzahl).
color_index: Index in die Farbpalette oder ``None`` für eine Zufallsfarbe.
"""
shape: ShapeKind
size: float
aspect_ratio: float
density: float
restitution: float
color_index: int | None = None
@classmethod
def from_config(cls, config: InteractionConfig) -> ToolSettings:
"""Startwerte aus der Konfiguration."""
return cls(
shape=ShapeKind.CIRCLE,
size=config.default_size,
aspect_ratio=config.default_aspect_ratio,
density=config.default_density,
restitution=config.default_restitution,
)
def half_extents(self) -> tuple[float, float]:
"""Halbe Breite und Höhe eines neuen Körpers.
Für Rechtecke wird die Größe mit √(Seitenverhältnis) gestreckt bzw. gestaucht. So
bleibt die Fläche – und damit die Masse – beim Ändern des Seitenverhältnisses gleich,
nur die Form ändert sich. Ausnahme: Keine halbe Kantenlänge darf unter
MIN_BODY_SIZE fallen, weil die Geschwindigkeitsgrenze gegen Tunneln auf dieser
Mindestgröße beruht. Bei sehr kleinen Rechtecken wird die kurze Seite deshalb
angehoben, und die Fläche wächst etwas.
"""
if self.shape is ShapeKind.CIRCLE:
return (self.size, self.size)
stretch = math.sqrt(self.aspect_ratio)
return (max(self.size * stretch, MIN_BODY_SIZE), max(self.size / stretch, MIN_BODY_SIZE))
@dataclass(frozen=True)
class BodyStyle:
"""Darstellungsdaten eines Körpers (liegen in ``Body.user_data``).
Attributes:
color: Füllfarbe.
"""
color: Color
class BodyFactory:
"""Erzeugt Körper gemäß den Werkzeug-Einstellungen und fügt sie der Welt hinzu.
Args:
world: Zielwelt.
config: Grenzen und Materialwerte.
palette: Verfügbare Farben.
rng: Zufallsgenerator für Zufallsfarben (injizierbar für reproduzierbare Tests).
"""
def __init__(
self,
world: World,
config: InteractionConfig,
palette: tuple[Color, ...],
rng: random.Random | None = None,
) -> None:
self.world = world
self.config = config
self.palette = palette
self._rng = rng or random.Random()
# Die nächste Zufallsfarbe wird im Voraus gewürfelt, damit die Vorschau-Kachel
# genau die Farbe zeigt, die das nächste Objekt bekommt.
self._next_random_color = self._rng.choice(palette)
@property
def is_full(self) -> bool:
"""True, wenn die Höchstzahl an Körpern erreicht ist."""
return len(self.world.bodies) >= self.config.max_bodies
def color_for(self, settings: ToolSettings) -> Color:
"""Farbe, die das nächste Objekt mit diesen Einstellungen bekommt."""
if settings.color_index is None:
return self._next_random_color
return self.palette[settings.color_index]
def create(self, settings: ToolSettings, position: Vector2) -> Body | None:
"""Erzeugt einen Körper an ``position`` (wird bei Bedarf in die Welt geschoben).
Returns:
Den neuen Körper oder ``None``, wenn die Höchstzahl erreicht ist.
"""
if self.is_full:
return None
half_width, half_height = settings.half_extents()
center = self.world.clamp_inside(position, (half_width, half_height))
material = Material(
density=settings.density,
restitution=settings.restitution,
friction=self.config.friction,
)
style = BodyStyle(self.color_for(settings))
if settings.color_index is None:
self._next_random_color = self._rng.choice(self.palette)
if settings.shape is ShapeKind.CIRCLE:
body = Body.circle(center, half_width, material, user_data=style)
else:
body = Body.box(center, half_width, half_height, material, user_data=style)
return self.world.add_body(body)
@@ -0,0 +1,79 @@
"""Geglättete Zeigergeschwindigkeit für das Werfen."""
from __future__ import annotations
import math
from collections import deque
from sandbox.physics import ZERO, Vector2
class VelocityTracker:
"""Schätzt, wie schnell sich die Maus zuletzt bewegt hat.
Die Geschwindigkeit zwischen nur zwei aufeinanderfolgenden Frames ist sehr unruhig:
Maus-Events kommen unregelmäßig, und ein einzelner kurzer Frame liefert einen riesigen
Wert. Deshalb zwei Stufen:
1. **Zeitfenster:** Mittlere Geschwindigkeit über die letzten ``window`` Sekunden
(Weg / Zeit zwischen ältester und neuester Probe) – das entspricht "der Bewegung der
letzten Frames" und ist unabhängig von der Bildrate.
2. **Exponentielle Glättung:** ``v ← v + (v_roh − v)·(1 − e^(−Δt/τ))``. Der
Exponentialterm macht die Glättung ebenfalls bildratenunabhängig: Zwei Frames à
8 ms glätten genauso stark wie ein Frame à 16 ms.
Hält man die Maus vor dem Loslassen still, sinkt die Geschwindigkeit schnell gegen 0 –
das Objekt fällt dann einfach herunter, statt mit einem veralteten Schwung davonzufliegen.
Args:
window: Länge des Mittelungsfensters in s.
smoothing_time: Zeitkonstante τ der exponentiellen Glättung in s.
"""
def __init__(self, window: float, smoothing_time: float) -> None:
if not (window > 0.0 and smoothing_time > 0.0):
raise ValueError("Fenster und Glättungszeit müssen positiv sein")
self.window = window
self.smoothing_time = smoothing_time
self._samples: deque[tuple[float, Vector2]] = deque()
self._velocity = ZERO
@property
def velocity(self) -> Vector2:
"""Aktuelle geglättete Geschwindigkeit in px/s."""
return self._velocity
def reset(self, time: float, position: Vector2) -> None:
"""Beginnt eine neue Messung (z. B. beim Greifen eines Körpers)."""
self._samples.clear()
self._samples.append((time, position))
self._velocity = ZERO
def add_sample(self, time: float, position: Vector2) -> None:
"""Nimmt eine neue Zeigerposition auf.
Args:
time: Zeitstempel in s (monoton steigend).
position: Zeigerposition in Weltkoordinaten.
"""
samples = self._samples
if not samples:
self.reset(time, position)
return
last_time = samples[-1][0]
elapsed = time - last_time
if elapsed <= 0.0:
# Mehrere Events im selben Frame: nur die neueste Position zählt.
samples[-1] = (last_time, position)
return
samples.append((time, position))
# Proben vor dem Fenster verwerfen – die jüngste davon bleibt als Bezugspunkt,
# damit das Fenster auch bei langen Frames nie leer ist. (Die neueste Probe liegt
# immer im Fenster, es bleiben also stets mindestens zwei Proben übrig.)
while len(samples) > 1 and samples[1][0] <= time - self.window:
samples.popleft()
oldest_time, oldest_position = samples[0]
raw = (position - oldest_position) / (time - oldest_time)
blend = 1.0 - math.exp(-elapsed / self.smoothing_time)
self._velocity = self._velocity + (raw - self._velocity) * blend
+283
View File
@@ -0,0 +1,283 @@
"""Einstiegspunkt und Hauptschleife der Physik-Sandbox.
Ablauf eines Frames (:meth:`App.frame`):
1. Events verarbeiten (Sidebar, Werkzeuge, Tastatur).
2. Die vergangene Zeit an den festen Zeitschritt geben; er liefert die Zahl fälliger
Physikschritte (bei 60 Bildern/s meist zwei Schritte à 1/120 s).
3. Vor jedem Physikschritt die Geschwindigkeit eines gezogenen Körpers setzen, dann
``World.step`` ausführen.
4. Zeichnen – mit Interpolation zwischen den letzten beiden Physikzuständen.
"""
from __future__ import annotations
import argparse
import random
import time
from pathlib import Path
import pygame
from sandbox.config import APP, MILLISECONDS_PER_SECOND, AppConfig, Color
from sandbox.demo import build_demo_scene
from sandbox.input_handler import InputHandler
from sandbox.interaction import BodyFactory, DragController, ToolSettings
from sandbox.physics import FixedTimestep, World
from sandbox.renderer import Renderer, WorldOverlay
from sandbox.ui.sidebar import Sidebar
from sandbox.ui.style import UiStyle
from sandbox.viewport import Viewport
class FrameStats:
"""Geglättete Bildrate und Physik-Rechenzeit für die Anzeige.
Args:
smoothing: Anteil (0..1), mit dem ein neuer Messwert pro Frame einfließt.
"""
def __init__(self, smoothing: float) -> None:
self.smoothing = smoothing
self.fps = 0.0
self.physics_ms = 0.0
def update(self, frame_time: float, physics_seconds: float) -> None:
"""Nimmt die Messwerte eines Frames auf."""
if frame_time > 0.0:
fps = 1.0 / frame_time
self.fps = fps if self.fps == 0.0 else self.fps + (fps - self.fps) * self.smoothing
physics_ms = physics_seconds * MILLISECONDS_PER_SECOND
self.physics_ms += (physics_ms - self.physics_ms) * self.smoothing
class App:
"""Die Anwendung: Fenster, Welt, Werkzeuge, Sidebar und Hauptschleife.
Args:
config: Gesamtkonfiguration.
rng: Zufallsgenerator für Zufallsfarben (für reproduzierbare Tests).
"""
def __init__(self, config: AppConfig = APP, *, rng: random.Random | None = None) -> None:
pygame.init()
self.config = config
window = config.window
self.screen = pygame.display.set_mode((window.width, window.height))
pygame.display.set_caption(window.title)
self.clock = pygame.time.Clock()
self.viewport = Viewport(
window.sidebar_width, 0, window.width - window.sidebar_width, window.height
)
self.world = World(self.viewport.width, self.viewport.height, config.physics)
self.timestep = FixedTimestep(config.physics.time_step, window.max_steps_per_frame)
self.tools = ToolSettings.from_config(config.interaction)
self.factory = BodyFactory(
self.world, config.interaction, config.theme.body_palette, rng or random.Random()
)
self.drag = DragController(self.world, config.interaction)
self.style = UiStyle(config.theme, config.ui)
self.input = InputHandler(
world=self.world,
viewport=self.viewport,
drag=self.drag,
factory=self.factory,
tools=self.tools,
config=config.interaction,
controls=self,
)
self.sidebar = Sidebar(
pygame.Rect(0, 0, window.sidebar_width, window.height),
self.style,
self.tools,
config.interaction,
config.theme.body_palette,
self,
)
self.input.sidebar = self.sidebar
self.renderer = Renderer(self.screen, self.viewport, self.style)
self.stats = FrameStats(window.stats_smoothing)
self.paused = False
self.debug = False
self.running = True
self.time = 0.0
# ------------------------------------------------------------------ Steuerung
def is_paused(self) -> bool:
"""Ob die Simulation pausiert ist."""
return self.paused
def toggle_pause(self) -> None:
"""Pausiert bzw. setzt die Simulation fort."""
self.paused = not self.paused
# Angesammelte Zeit verwerfen, sonst holte die Physik nach der Pause Schritte nach.
self.timestep.reset()
def gravity_enabled(self) -> bool:
"""Ob die Schwerkraft wirkt."""
return self.world.gravity_enabled
def toggle_gravity(self) -> None:
"""Schaltet die Schwerkraft an bzw. aus."""
self.world.gravity_enabled = not self.world.gravity_enabled
def clear_world(self) -> None:
"""Entfernt alle Körper."""
self.input.forget_bodies()
self.world.clear()
def begin_spawn_drag(self) -> None:
"""Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel."""
self.input.begin_spawn_drag()
def preview_color(self) -> Color:
"""Farbe des nächsten Objekts."""
return self.factory.color_for(self.tools)
def status_lines(self) -> list[str]:
"""Statuszeilen am unteren Rand der Sidebar."""
limit = self.config.interaction.max_bodies
count = len(self.world.bodies)
full = " (voll)" if count >= limit else ""
return [
f"Objekte: {count} / {limit}{full}",
f"{self.stats.fps:.0f} FPS · Physik {self.stats.physics_ms:.1f} ms",
"Rechtsklick: löschen · Rad: Größe",
"F1: Debug · Esc: Beenden",
]
def request_quit(self) -> None:
"""Beendet die Hauptschleife nach dem aktuellen Frame."""
self.running = False
def toggle_debug(self) -> None:
"""Schaltet die Debug-Ansicht (Kontakte, Schlafzustand) um."""
self.debug = not self.debug
def load_demo_scene(self) -> None:
"""Ersetzt den Inhalt der Welt durch die Beispielszene."""
self.clear_world()
build_demo_scene(self.factory, self.tools, self.config.demo)
# ------------------------------------------------------------------ Hauptschleife
def frame(self, frame_time: float) -> None:
"""Verarbeitet Eingaben, rechnet die Physik und zeichnet ein Bild.
Args:
frame_time: Real vergangene Zeit seit dem letzten Frame in s.
"""
self.time += frame_time
for event in pygame.event.get():
self.input.handle_event(event, self.time)
self.input.update(self.time)
physics_start = time.perf_counter()
if self.paused:
self.drag.place() # Im Pausenmodus wird direkt verschoben, ohne Physik.
else:
dt = self.timestep.step
for _ in range(self.timestep.advance(frame_time)):
self.drag.apply(dt)
self.world.step(dt)
self.stats.update(frame_time, time.perf_counter() - physics_start)
self.draw()
pygame.display.flip()
def draw(self) -> None:
"""Zeichnet Welt, Sidebar und – obenauf – die Vorschau eines neuen Objekts."""
overlay = WorldOverlay(
hovered=self.input.hovered_body,
held=self.drag.body,
throw_velocity=self.drag.throw_velocity,
paused=self.paused,
debug=self.debug,
debug_lines=self._debug_lines() if self.debug else [],
)
alpha = 1.0 if self.paused else self.timestep.alpha
self.renderer.draw_world(self.world, alpha, overlay)
self.sidebar.draw(self.screen)
preview = self.input.spawn_preview()
if preview is not None:
self.renderer.draw_preview(preview)
def _debug_lines(self) -> list[str]:
awake = sum(not body.sleeping for body in self.world.bodies)
return [
f"Körper: {len(self.world.bodies)} (wach: {awake})",
f"Kontakte: {len(self.world.contacts)}",
f"Physik: {self.stats.physics_ms:.2f} ms/Frame · Schritt {self.world.step_count}",
]
def run(self, max_seconds: float | None = None) -> None:
"""Führt die Hauptschleife aus, bis das Fenster geschlossen wird.
Args:
max_seconds: Optional; beendet die Schleife nach dieser Laufzeit (für
Screenshots und den Smoke-Test).
"""
start = time.perf_counter()
self.clock.tick() # Zeitmessung ab jetzt, nicht ab dem Erzeugen der App
while self.running:
frame_time = self.clock.tick(self.config.window.fps_limit) / MILLISECONDS_PER_SECOND
self.frame(frame_time)
if max_seconds is not None and time.perf_counter() - start >= max_seconds:
break
def save_screenshot(self, path: Path) -> None:
"""Speichert den aktuellen Fensterinhalt als Bild."""
path.parent.mkdir(parents=True, exist_ok=True)
pygame.image.save(self.screen, str(path))
def close(self) -> None:
"""Gibt Fenster und pygame-Ressourcen frei."""
pygame.quit()
def main(argv: list[str] | None = None) -> int:
"""Startet die Sandbox (Kommandozeilen-Einstiegspunkt).
Args:
argv: Kommandozeilenargumente (Standard: ``sys.argv[1:]``).
Returns:
Exit-Code 0.
"""
parser = argparse.ArgumentParser(
prog="physics-sandbox", description="Interaktive 2D-Physik-Sandbox"
)
parser.add_argument("--demo", action="store_true", help="mit einer Beispielszene starten")
parser.add_argument(
"--screenshot",
type=Path,
metavar="DATEI",
help="nach --seconds Sekunden ein Bildschirmfoto speichern und beenden",
)
parser.add_argument(
"--seconds",
type=float,
default=APP.window.screenshot_delay,
help="Laufzeit vor dem Bildschirmfoto (Standard: %(default)s)",
)
args = parser.parse_args(argv)
app = App()
try:
if args.demo:
app.load_demo_scene()
if args.screenshot is not None:
app.run(max_seconds=args.seconds)
app.save_screenshot(args.screenshot)
else:
app.run()
finally:
app.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
"""Pygame-freie 2D-Physik-Engine.
Die Physik verwendet ausschließlich das Standardmodul ``math`` (plus ``typing`` und
``dataclasses`` für Typangaben und Datenstrukturen). Ein Architektur-Test stellt sicher,
dass hier nie pygame oder eine andere Bibliothek importiert wird.
Wichtigste Bausteine:
* :class:`Vector2` – eigene Vektorklasse.
* :class:`Circle`, :class:`Box`, :class:`HalfPlane` – Formen.
* :class:`Body`, :class:`Material` – starre Körper.
* :class:`World` – verwaltet Körper und führt Simulationsschritte aus.
* :class:`FixedTimestep` – übersetzt Frame-Zeiten in feste Physikschritte.
"""
from sandbox.physics.body import Body, Material
from sandbox.physics.collision import Contact, collide
from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape
from sandbox.physics.timestep import FixedTimestep
from sandbox.physics.vector import ZERO, Vector2
from sandbox.physics.world import World
__all__ = [
"ZERO",
"Body",
"Box",
"Circle",
"Contact",
"FixedTimestep",
"HalfPlane",
"Material",
"Shape",
"Vector2",
"World",
"collide",
]
+278
View File
@@ -0,0 +1,278 @@
"""Starre Körper und ihre Materialeigenschaften."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import ClassVar
from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape
from sandbox.physics.vector import ZERO, Vector2
@dataclass(frozen=True, slots=True)
class Material:
"""Stoffeigenschaften eines Körpers.
Attributes:
density: Masse pro Fläche. Die Masse eines Körpers ist Fläche × Dichte, größere
Körper sind bei gleicher Dichte also schwerer.
restitution: Stoßzahl e ∈ [0, 1]. Verhältnis von Trenn- zu Aufprallgeschwindigkeit:
0 = völlig unelastisch (kein Abprallen), 1 = vollkommen elastisch.
friction: Reibungskoeffizient μ ≥ 0 (Coulomb: Reibkraft ≤ μ · Normalkraft).
"""
density: float
restitution: float
friction: float
def __post_init__(self) -> None:
if self.density < 0.0:
raise ValueError(f"Dichte darf nicht negativ sein: {self.density}")
if not 0.0 <= self.restitution <= 1.0:
raise ValueError(f"Restitution muss in [0, 1] liegen: {self.restitution}")
if self.friction < 0.0:
raise ValueError(f"Reibung darf nicht negativ sein: {self.friction}")
class Body:
"""Ein starrer Körper der Simulation.
Dynamische Körper haben eine endliche Masse (Fläche × Dichte) und werden von Schwerkraft
und Kontakten bewegt. Statische Körper (die Wände) haben unendliche Masse; gespeichert
wird dafür die *inverse* Masse 0. Mit inversen Massen lassen sich beide Fälle ohne
Sonderbehandlung in dieselben Formeln einsetzen: Ein Impuls ändert die Geschwindigkeit
um ``Impuls · inv_mass`` – bei statischen Körpern also gar nicht.
Attributes:
id: Eindeutige, fortlaufende Kennung (für Kontakt-Caches und deterministische
Reihenfolgen).
shape: Geometrie des Körpers.
material: Dichte, Restitution und Reibung.
position: Position des Schwerpunkts in px.
velocity: Geschwindigkeit des Schwerpunkts in px/s.
angle: Drehwinkel in rad (nur Kreise drehen sich).
angular_velocity: Winkelgeschwindigkeit in rad/s.
is_static: True für unbewegliche Körper (Wände).
mass: Masse (``math.inf`` bei statischen Körpern).
inv_mass: 1 / Masse (0 bei statischen Körpern).
inertia: Trägheitsmoment (``math.inf``, wenn sich der Körper nicht drehen kann).
inv_inertia: 1 / Trägheitsmoment (0, wenn sich der Körper nicht drehen kann).
sleeping: True, solange der Körper im Schlafmodus eingefroren ist.
sleep_time: Wie lange (s) der Körper schon ununterbrochen fast stillsteht.
sleep_island: Die Körper, mit denen dieser Körper gemeinsam eingeschlafen ist
(einschließlich ihm selbst), oder ``None``. Sie werden nur gemeinsam geweckt,
siehe :meth:`wake`.
held: True, solange der Körper mit der Maus gezogen wird. Gehaltene Körper
unterliegen keiner Schwerkraft und schlafen nie ein.
previous_position: Position zu Beginn des letzten Schritts (für die
Zeichen-Interpolation zwischen zwei Physikschritten).
previous_angle: Winkel zu Beginn des letzten Schritts.
previous_velocity: Geschwindigkeit zu Beginn des letzten Schritts, also vor
Schwerkraft und Kontakten. Daraus bestimmt der Solver die Aufprallgeschwindigkeit
für das Abprallen.
user_data: Beliebige Zusatzdaten der Anwendung (z. B. die Farbe). Die Physik
liest dieses Feld nie.
"""
__slots__ = (
"angle",
"angular_velocity",
"held",
"id",
"inertia",
"inv_inertia",
"inv_mass",
"is_static",
"mass",
"material",
"position",
"previous_angle",
"previous_position",
"previous_velocity",
"shape",
"sleep_island",
"sleep_time",
"sleeping",
"user_data",
"velocity",
)
_next_id: ClassVar[int] = 1
def __init__(
self,
shape: Shape,
position: Vector2,
material: Material,
*,
is_static: bool = False,
velocity: Vector2 = ZERO,
angular_velocity: float = 0.0,
user_data: object = None,
) -> None:
self.id = Body._next_id
Body._next_id += 1
self.shape = shape
self.material = material
self.position = position
self.previous_position = position
self.angle = 0.0
self.previous_angle = 0.0
self.is_static = is_static
self.sleeping = False
self.sleep_time = 0.0
self.sleep_island: list[Body] | None = None
self.held = False
self.user_data = user_data
if is_static:
self.mass = math.inf
self.inv_mass = 0.0
self.inertia = math.inf
self.inv_inertia = 0.0
else:
if isinstance(shape, HalfPlane):
raise ValueError("Halbebenen können nur statische Körper sein")
mass = shape.area() * material.density
if not mass > 0.0:
raise ValueError("Dynamische Körper brauchen eine Dichte > 0")
self.mass = mass
self.inv_mass = 1.0 / mass
self.inertia = shape.moment_of_inertia(mass)
self.inv_inertia = 0.0 if math.isinf(self.inertia) else 1.0 / self.inertia
# Statische Körper bewegen sich nie; Körper ohne Drehfreiheit drehen sich nie.
self.velocity = ZERO if is_static else velocity
self.previous_velocity = self.velocity
self.angular_velocity = angular_velocity if self.inv_inertia > 0.0 else 0.0
# ------------------------------------------------------------------ Fabrikmethoden
@classmethod
def circle(
cls,
position: Vector2,
radius: float,
material: Material,
*,
velocity: Vector2 = ZERO,
user_data: object = None,
) -> Body:
"""Erzeugt einen dynamischen Kreis."""
return cls(Circle(radius), position, material, velocity=velocity, user_data=user_data)
@classmethod
def box(
cls,
position: Vector2,
half_width: float,
half_height: float,
material: Material,
*,
velocity: Vector2 = ZERO,
user_data: object = None,
) -> Body:
"""Erzeugt ein dynamisches achsenparalleles Rechteck."""
return cls(
Box(half_width, half_height), position, material, velocity=velocity, user_data=user_data
)
@classmethod
def wall(cls, normal: Vector2, offset: float, material: Material) -> Body:
"""Erzeugt eine statische Wand (Halbebene ``normal·p ≥ offset`` ist frei)."""
return cls(HalfPlane(normal, offset), ZERO, material, is_static=True)
# ------------------------------------------------------------------ Abfragen
@property
def can_rotate(self) -> bool:
"""True, wenn Drehimpulse den Körper drehen können (nur dynamische Kreise)."""
return self.inv_inertia > 0.0
def half_extents(self) -> tuple[float, float]:
"""Halbe Breite und Höhe der umschließenden achsenparallelen Box."""
return self.shape.half_extents()
def bounds(self) -> tuple[float, float, float, float]:
"""Umschließende Box als ``(min_x, min_y, max_x, max_y)``."""
half_x, half_y = self.shape.half_extents()
x = self.position.x
y = self.position.y
return (x - half_x, y - half_y, x + half_x, y + half_y)
def contains_point(self, point: Vector2, tolerance: float = 0.0) -> bool:
"""Prüft, ob ein Weltpunkt im Körper liegt (``tolerance`` vergrößert den Körper)."""
return self.shape.contains(point - self.position, tolerance)
def kinetic_energy(self) -> float:
"""Bewegungsenergie ``½·m·v² + ½·I·ω²`` (0 für statische Körper)."""
if self.is_static:
return 0.0
energy = 0.5 * self.mass * self.velocity.length_squared()
if self.can_rotate:
energy += 0.5 * self.inertia * self.angular_velocity * self.angular_velocity
return energy
def momentum(self) -> Vector2:
"""Impuls ``p = m·v``."""
if self.is_static:
return ZERO
return self.velocity * self.mass
# ------------------------------------------------------------------ Zustandsänderungen
def apply_impulse(self, impulse: Vector2, offset: Vector2 = ZERO) -> None:
"""Wendet einen Impuls an einem Punkt an.
Args:
impulse: Impuls (Kraftstoß) in Masse·px/s.
offset: Angriffspunkt relativ zum Schwerpunkt. Ein Impuls, der nicht durch den
Schwerpunkt geht, erzeugt zusätzlich einen Drehimpuls ``offset × impulse``.
"""
if self.is_static:
return
self.velocity = self.velocity + impulse * self.inv_mass
self.angular_velocity += self.inv_inertia * offset.cross(impulse)
def wake(self) -> None:
"""Weckt den Körper – und alle Körper, mit denen er eingeschlafen ist.
Warum die ganze Insel? Zwei schlafende Körper werden nicht auf Kontakte
untereinander geprüft. Würde nur die unterste Kiste eines schlafenden Stapels
geweckt und im selben Schritt weggestoßen (oder in der Pause weggezogen), bemerkte
niemand, dass die Kisten darüber ihre Stütze verloren haben – sie schwebten.
"""
island = self.sleep_island
for body in island if island is not None else (self,):
body.sleeping = False
body.sleep_time = 0.0
body.sleep_island = None
def put_to_sleep(self, island: list[Body] | None = None) -> None:
"""Friert den Körper ein: keine Bewegung, bis ihn etwas aufweckt.
Args:
island: Die Körper, die gemeinsam mit diesem einschlafen (einschließlich
ihm selbst). Weckt später einer von ihnen auf, wachen alle auf.
"""
self.sleeping = True
self.sleep_island = island
self.velocity = ZERO
self.previous_velocity = ZERO
self.angular_velocity = 0.0
# Ohne diese Angleichung würde die Zeichen-Interpolation einen längst ruhenden
# Körper noch zwischen zwei alten Positionen hin- und herschieben.
self.previous_position = self.position
self.previous_angle = self.angle
def teleport(self, position: Vector2) -> None:
"""Setzt die Position direkt (ohne Interpolations-Schlieren) und weckt den Körper."""
self.position = position
self.previous_position = position
self.wake()
def __repr__(self) -> str:
state = " schläft" if self.sleeping else ""
return f"Body(id={self.id}, {self.shape!r}, position={self.position!r}{state})"
+51
View File
@@ -0,0 +1,51 @@
"""Grobe Vorauswahl möglicher Kollisionspaare ("Broadphase") per Sweep-and-Prune.
Warum: Jedes Paar von n Körpern genau zu prüfen kostet n·(n−1)/2 Tests – bei 150 Körpern
über 11 000 pro Schritt, 120-mal pro Sekunde. In reinem Python ist das zu langsam.
Sweep-and-Prune sortiert die umschließenden Boxen nach ihrer linken Kante und läuft einmal
von links nach rechts. Für jede Box müssen nur die Nachfolger geprüft werden, deren linke
Kante noch vor ihrer rechten Kante liegt; alle weiteren beginnen noch weiter rechts und
können nicht überlappen. Der Aufwand sinkt so auf etwa O(n·log n + k) für k tatsächlich
benachbarte Paare. Da sich die Körper zwischen zwei Schritten kaum bewegen, ist die Liste
fast sortiert, und Pythons Timsort sortiert sie besonders schnell.
"""
from __future__ import annotations
from sandbox.physics.body import Body
def find_candidate_pairs(bodies: list[Body]) -> list[tuple[Body, Body]]:
"""Findet alle Körperpaare, deren umschließende Boxen sich überlappen oder berühren.
Paare aus zwei schlafenden Körpern werden übersprungen: Sie bewegen sich nicht, ihr
Kontakt ändert sich also nicht. Jedes Paar ist nach ``id`` geordnet, damit die
Reihenfolge (und damit die ganze Simulation) reproduzierbar ist.
Args:
bodies: Die dynamischen Körper der Welt.
Returns:
Kandidatenpaare für die genaue Prüfung mit :func:`~sandbox.physics.collision.collide`.
"""
entries = sorted(((body.bounds(), body) for body in bodies), key=_left_edge)
pairs: list[tuple[Body, Body]] = []
count = len(entries)
for i in range(count):
(_, min_y, max_x, max_y), body = entries[i]
for j in range(i + 1, count):
(other_min_x, other_min_y, _, other_max_y), other = entries[j]
if other_min_x > max_x:
break # Alle weiteren Boxen beginnen noch weiter rechts.
if other_min_y > max_y or other_max_y < min_y:
continue
if body.sleeping and other.sleeping:
continue
pairs.append((body, other) if body.id < other.id else (other, body))
return pairs
def _left_edge(entry: tuple[tuple[float, float, float, float], Body]) -> float:
"""Sortierschlüssel: linke Kante der umschließenden Box."""
return entry[0][0]
+266
View File
@@ -0,0 +1,266 @@
"""Kollisionserkennung ("Narrowphase"): Berühren sich zwei Körper, und wenn ja, wie?
Jede Testfunktion liefert entweder ``None`` (keine Berührung) oder einen :class:`Contact`
mit drei Informationen, die die Kollisionsauflösung braucht:
* **Normale** n – Einheitsvektor, der von Körper A zu Körper B zeigt. Entlang n werden
die Körper auseinandergedrückt.
* **Eindringtiefe** – wie weit sich die Körper entlang n überlappen (≥ 0).
* **Kontaktpunkt** – wo die Kraft angreift. Er bestimmt die Hebelarme für die Drehung.
Konvention: :func:`collide` ordnet die Körper so, dass Körper A die "einfachere" Form hat
(Kreis vor Rechteck vor Wand). Für ein Körperpaar ist die Orientierung dadurch in jedem
Schritt gleich, was das Warm-Starting des Solvers (Wiederverwenden alter Impulse) erlaubt.
"""
from __future__ import annotations
import math
from typing import Final
from sandbox.config import NUMERIC_EPSILON
from sandbox.physics.body import Body
from sandbox.physics.shapes import Box, Circle, HalfPlane
from sandbox.physics.vector import Vector2
#: Ersatznormale, wenn zwei Kreismittelpunkte exakt aufeinanderliegen und die Richtung
#: daher unbestimmt ist. Jede Richtung wäre richtig; eine feste macht das Ergebnis
#: reproduzierbar. (0, −1) schiebt Körper B nach oben, A nach unten.
COINCIDENT_NORMAL: Final = Vector2(0.0, -1.0)
#: Reihenfolge der Formen für die kanonische Anordnung in :func:`collide`.
_SHAPE_ORDER: Final = {Circle: 0, Box: 1, HalfPlane: 2}
class Contact:
"""Berührung zweier Körper in einem Simulationsschritt.
Attributes:
body_a: Erster Körper.
body_b: Zweiter Körper.
normal: Einheitsnormale von A nach B.
penetration: Eindringtiefe entlang der Normalen in px (≥ 0).
point: Kontaktpunkt in Weltkoordinaten.
normal_impulse: Vom Solver aufgebrachter Normalimpuls (Ergebnis, ≥ 0).
tangent_impulse: Vom Solver aufgebrachter Reibungsimpuls (Ergebnis).
"""
__slots__ = (
"body_a",
"body_b",
"normal",
"normal_impulse",
"penetration",
"point",
"tangent_impulse",
)
def __init__(
self, body_a: Body, body_b: Body, normal: Vector2, penetration: float, point: Vector2
) -> None:
self.body_a = body_a
self.body_b = body_b
self.normal = normal
self.penetration = penetration
self.point = point
self.normal_impulse = 0.0
self.tangent_impulse = 0.0
@property
def key(self) -> tuple[int, int]:
"""Schlüssel des Körperpaares, um Kontakte über Schritte hinweg wiederzuerkennen."""
return (self.body_a.id, self.body_b.id)
def __repr__(self) -> str:
return (
f"Contact({self.body_a.id}->{self.body_b.id}, normal={self.normal!r}, "
f"penetration={self.penetration:.3f})"
)
def collide(first: Body, second: Body) -> Contact | None:
"""Prüft zwei beliebige Körper auf Berührung.
Die Körper werden vorher kanonisch angeordnet (siehe Moduldokumentation). Das Ergebnis
kann ``body_a`` und ``body_b`` daher in vertauschter Reihenfolge enthalten.
Returns:
Den Kontakt oder ``None``, wenn sich die Körper nicht berühren.
"""
body_a, body_b = first, second
if _SHAPE_ORDER[type(body_b.shape)] < _SHAPE_ORDER[type(body_a.shape)]:
body_a, body_b = body_b, body_a
shape_a = body_a.shape
shape_b = body_b.shape
if isinstance(shape_a, Circle):
if isinstance(shape_b, Circle):
return collide_circles(body_a, body_b)
if isinstance(shape_b, Box):
return collide_circle_box(body_a, body_b)
return collide_circle_half_plane(body_a, body_b)
if isinstance(shape_a, Box):
if isinstance(shape_b, Box):
return collide_boxes(body_a, body_b)
return collide_box_half_plane(body_a, body_b)
return None # Zwei Wände kollidieren nie miteinander.
def collide_circles(body_a: Body, body_b: Body) -> Contact | None:
"""Kreis gegen Kreis.
Zwei Kreise überlappen, wenn der Mittelpunktabstand kleiner als die Summe der Radien
ist. Die Normale zeigt entlang der Verbindungslinie der Mittelpunkte.
"""
radius_a = body_a.shape.radius
radius_b = body_b.shape.radius
dx = body_b.position.x - body_a.position.x
dy = body_b.position.y - body_a.position.y
distance_sq = dx * dx + dy * dy
radius_sum = radius_a + radius_b
if distance_sq > radius_sum * radius_sum:
return None
if distance_sq > NUMERIC_EPSILON * NUMERIC_EPSILON:
distance = math.sqrt(distance_sq)
normal = Vector2(dx / distance, dy / distance)
else:
distance = 0.0
normal = COINCIDENT_NORMAL
penetration = radius_sum - distance
# Kontaktpunkt: Mitte des überlappenden Bereichs auf der Verbindungslinie.
point = body_a.position + normal * (radius_a - 0.5 * penetration)
return Contact(body_a, body_b, normal, penetration, point)
def collide_circle_box(circle_body: Body, box_body: Body) -> Contact | None:
"""Kreis (Körper A) gegen achsenparalleles Rechteck (Körper B).
Idee: Den Kreismittelpunkt komponentenweise auf das Rechteck klemmen. Das liefert den
Punkt des Rechtecks, der dem Kreismittelpunkt am nächsten liegt. Ist er weniger als
einen Radius entfernt, berühren sich die Körper.
Sonderfall: Liegt der Mittelpunkt *im* Rechteck, ist der geklemmte Punkt der
Mittelpunkt selbst und liefert keine Richtung. Dann wird der Kreis über die nächste
Kante hinausgeschoben (Achse mit der kleinsten Eindringtiefe).
"""
radius = circle_body.shape.radius
half_width = box_body.shape.half_width
half_height = box_body.shape.half_height
box_x = box_body.position.x
box_y = box_body.position.y
# Kreismittelpunkt relativ zum Rechteckmittelpunkt.
dx = circle_body.position.x - box_x
dy = circle_body.position.y - box_y
closest_x = min(max(dx, -half_width), half_width)
closest_y = min(max(dy, -half_height), half_height)
if closest_x != dx or closest_y != dy:
# Mittelpunkt außerhalb: Abstand zum nächsten Punkt des Rechtecks.
offset_x = dx - closest_x
offset_y = dy - closest_y
distance_sq = offset_x * offset_x + offset_y * offset_y
if distance_sq > radius * radius:
return None
distance = math.sqrt(distance_sq) # > 0, weil der Mittelpunkt außerhalb liegt
# (offset) zeigt vom Rechteck zum Kreis; die Normale soll von A (Kreis) zu B zeigen.
normal = Vector2(-offset_x / distance, -offset_y / distance)
penetration = radius - distance
point = Vector2(box_x + closest_x, box_y + closest_y)
return Contact(circle_body, box_body, normal, penetration, point)
# Mittelpunkt innerhalb (oder genau auf dem Rand): über die nächste Kante hinausdrücken.
gap_x = half_width - abs(dx)
gap_y = half_height - abs(dy)
if gap_x < gap_y:
side = 1.0 if dx >= 0.0 else -1.0
# Der Kreis muss in Richtung "side" hinaus, er bewegt sich entlang −n → n = −side.
normal = Vector2(-side, 0.0)
penetration = radius + gap_x
point = Vector2(box_x + side * half_width, circle_body.position.y)
else:
side = 1.0 if dy >= 0.0 else -1.0
normal = Vector2(0.0, -side)
penetration = radius + gap_y
point = Vector2(circle_body.position.x, box_y + side * half_height)
return Contact(circle_body, box_body, normal, penetration, point)
def collide_boxes(body_a: Body, body_b: Body) -> Contact | None:
"""Rechteck gegen Rechteck (beide achsenparallel).
Zwei achsenparallele Rechtecke überlappen genau dann, wenn sich ihre Projektionen auf
beide Achsen überlappen (Trennachsensatz). Getrennt wird entlang der Achse mit der
kleineren Überlappung – das ist der kürzeste Weg aus der Durchdringung. Bei Gleichstand
gewinnt die y-Achse, weil aufeinanderliegende Körper der häufigste Fall sind.
"""
shape_a = body_a.shape
shape_b = body_b.shape
dx = body_b.position.x - body_a.position.x
dy = body_b.position.y - body_a.position.y
overlap_x = shape_a.half_width + shape_b.half_width - abs(dx)
if overlap_x < 0.0:
return None
overlap_y = shape_a.half_height + shape_b.half_height - abs(dy)
if overlap_y < 0.0:
return None
if overlap_x < overlap_y:
normal = Vector2(1.0 if dx >= 0.0 else -1.0, 0.0)
penetration = overlap_x
else:
normal = Vector2(0.0, 1.0 if dy >= 0.0 else -1.0)
penetration = overlap_y
# Kontaktpunkt: Mittelpunkt des Überlappungsrechtecks. Da Rechtecke sich nie drehen,
# beeinflusst er nur die Debug-Anzeige, nicht die Physik.
a_x = body_a.position.x
a_y = body_a.position.y
b_x = body_b.position.x
b_y = body_b.position.y
left = max(a_x - shape_a.half_width, b_x - shape_b.half_width)
right = min(a_x + shape_a.half_width, b_x + shape_b.half_width)
top = max(a_y - shape_a.half_height, b_y - shape_b.half_height)
bottom = min(a_y + shape_a.half_height, b_y + shape_b.half_height)
point = Vector2(0.5 * (left + right), 0.5 * (top + bottom))
return Contact(body_a, body_b, normal, penetration, point)
def collide_circle_half_plane(circle_body: Body, wall_body: Body) -> Contact | None:
"""Kreis gegen Wand (Halbebene).
Der Kreis berührt die Wand, wenn der Abstand seines Mittelpunkts zur Wandlinie kleiner
als der Radius ist. Liegt der Mittelpunkt sogar hinter der Wand, ist der Abstand
negativ und die Eindringtiefe entsprechend groß – der Kreis wird trotzdem korrekt in
den freien Raum zurückgeschoben.
"""
wall = wall_body.shape
center = circle_body.position
distance = wall.distance(center)
penetration = circle_body.shape.radius - distance
if penetration < 0.0:
return None
# Die Wandnormale zeigt in den freien Raum, also zum Kreis; A→B ist die Gegenrichtung.
normal = -wall.normal
point = center - wall.normal * distance # Lotfußpunkt auf der Wandlinie
return Contact(circle_body, wall_body, normal, penetration, point)
def collide_box_half_plane(box_body: Body, wall_body: Body) -> Contact | None:
"""Rechteck gegen Wand (Halbebene).
Die "Reichweite" eines achsenparallelen Rechtecks entlang der Normalen n ist
``|n.x|·hw + |n.y|·hh`` (Projektion der Halbdiagonale). Ist der Mittelpunkt näher an der
Wand als diese Reichweite, ragt das Rechteck in die Wand.
"""
wall = wall_body.shape
shape = box_body.shape
center = box_body.position
reach = abs(wall.normal.x) * shape.half_width + abs(wall.normal.y) * shape.half_height
distance = wall.distance(center)
penetration = reach - distance
if penetration < 0.0:
return None
normal = -wall.normal
point = center - wall.normal * distance
return Contact(box_body, wall_body, normal, penetration, point)
+139
View File
@@ -0,0 +1,139 @@
"""Geometrische Formen der Körper: Kreis, achsenparalleles Rechteck und Halbebene.
Formen beschreiben nur die Geometrie relativ zum Schwerpunkt. Position, Geschwindigkeit
und Masse gehören zum :class:`~sandbox.physics.body.Body`.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import TypeAlias
from sandbox.config import NUMERIC_EPSILON
from sandbox.physics.vector import Vector2
@dataclass(frozen=True, slots=True)
class Circle:
"""Homogene Kreisscheibe. Kreise können sich drehen (z. B. rollen).
Attributes:
radius: Radius in px (> 0).
"""
radius: float
def __post_init__(self) -> None:
if not self.radius > 0.0:
raise ValueError(f"Radius muss positiv sein, ist aber {self.radius}")
def area(self) -> float:
"""Fläche ``π·r²`` – bestimmt zusammen mit der Dichte die Masse."""
return math.pi * self.radius * self.radius
def moment_of_inertia(self, mass: float) -> float:
"""Trägheitsmoment einer homogenen Scheibe um ihren Mittelpunkt: ``I = ½·m·r²``.
Herleitung: Die Scheibe besteht aus dünnen Ringen mit Radius s und Masse
``dm = m/(π·r²) · 2π·s·ds``. Jeder Ring trägt ``s²·dm`` bei, also
``I = (2m/r²)·∫₀ʳ s³ ds = (2m/r²)·r⁴/4 = ½·m·r²``.
"""
return 0.5 * mass * self.radius * self.radius
def half_extents(self) -> tuple[float, float]:
"""Halbe Breite und Höhe der umschließenden achsenparallelen Box."""
return (self.radius, self.radius)
def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool:
"""Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Kreis liegt."""
reach = self.radius + tolerance
return offset.length_squared() <= reach * reach
@dataclass(frozen=True, slots=True)
class Box:
"""Achsenparalleles Rechteck (AABB).
Rechtecke bleiben per Definition achsenparallel. Deshalb liefert
:meth:`moment_of_inertia` "unendlich": Das inverse Trägheitsmoment ist dann 0, und kein
Drehimpuls kann das Rechteck je drehen.
Attributes:
half_width: Halbe Breite in px (> 0).
half_height: Halbe Höhe in px (> 0).
"""
half_width: float
half_height: float
def __post_init__(self) -> None:
if not (self.half_width > 0.0 and self.half_height > 0.0):
raise ValueError(
f"Halbe Kantenlängen müssen positiv sein: {self.half_width}, {self.half_height}"
)
def area(self) -> float:
"""Fläche ``(2·hw)·(2·hh)``."""
return 4.0 * self.half_width * self.half_height
def moment_of_inertia(self, mass: float) -> float:
"""Unendlich, damit sich das Rechteck nie dreht (siehe Klassenbeschreibung).
``mass`` wird nur für eine einheitliche Schnittstelle mit :class:`Circle` angenommen.
"""
del mass
return math.inf
def half_extents(self) -> tuple[float, float]:
"""Halbe Breite und Höhe."""
return (self.half_width, self.half_height)
def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool:
"""Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Rechteck liegt."""
return (
abs(offset.x) <= self.half_width + tolerance
and abs(offset.y) <= self.half_height + tolerance
)
@dataclass(frozen=True, slots=True)
class HalfPlane:
"""Unendliche Halbebene – modelliert Boden, Decke und Seitenwände.
Die Ebene ist durch ``normal·p = offset`` gegeben. Die Normale zeigt in den freien
Raum; alles mit ``normal·p < offset`` ist "in der Wand". Weil eine Halbebene unendlich
dick ist, kann kein Körper durch sie hindurchtunneln: Selbst ein Körper, der in einem
Schritt komplett hinter die Wandlinie springt, wird als (tiefe) Durchdringung erkannt
und zurückgeschoben.
Attributes:
normal: Einheitsnormale, zeigt in den freien Raum.
offset: Abstand der Ebene vom Ursprung entlang der Normalen.
"""
normal: Vector2
offset: float
def __post_init__(self) -> None:
if abs(self.normal.length() - 1.0) > NUMERIC_EPSILON:
raise ValueError(f"Die Normale muss Länge 1 haben: {self.normal}")
def distance(self, point: Vector2) -> float:
"""Vorzeichenbehafteter Abstand eines Punktes (positiv = im freien Raum)."""
return self.normal.dot(point) - self.offset
def half_extents(self) -> tuple[float, float]:
"""Eine Halbebene ist unbegrenzt."""
return (math.inf, math.inf)
def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool:
"""Prüft, ob ein Punkt in der Wand (hinter der Ebene) liegt.
Wandkörper liegen im Ursprung, deshalb ist ``offset`` hier der Punkt selbst.
"""
return self.distance(offset) <= tolerance
#: Alle unterstützten Formen.
Shape: TypeAlias = Circle | Box | HalfPlane
+126
View File
@@ -0,0 +1,126 @@
"""Schlafmodus: ruhende Körper einfrieren, bis etwas sie anstößt.
Warum: Ein Solver mit endlich vielen Iterationen lässt immer winzige Restbewegungen übrig.
Ein Stapel "atmet" dadurch sichtbar oder kriecht. Friert man Körper ein, die eine Weile
praktisch stillstehen, liegen sie wirklich ruhig – und sparen Rechenzeit, weil sie weder
integriert noch aufgelöst werden müssen.
Warum gruppenweise ("Inseln")? Würde jeder Körper einzeln einschlafen, könnte ein
schlafender Körper unter einem wachen liegen: Der wache weckt ihn, beide ruhen, der eine
schläft früher ein, der andere weckt ihn wieder – endloses Hin und Her. Deshalb werden
Körper, die sich (direkt oder über andere) berühren, zu einer Insel zusammengefasst, und
eine Insel schläft nur als Ganzes ein – und wacht auch nur als Ganzes wieder auf. Wände
verbinden keine Inseln, sonst wäre alles auf dem Boden eine einzige Insel.
"""
from __future__ import annotations
from sandbox.config import PhysicsConfig
from sandbox.physics.body import Body
from sandbox.physics.collision import Contact
class UnionFind:
"""Disjunkte Mengen ("Union-Find") zum Bilden von Kontakt-Inseln.
Jede Menge wird durch einen Repräsentanten identifiziert. :meth:`union` verschmilzt zwei
Mengen, :meth:`find` liefert den Repräsentanten – dank Pfadverkürzung in nahezu
konstanter Zeit.
"""
def __init__(self, keys: list[int]) -> None:
self._parent = {key: key for key in keys}
def find(self, key: int) -> int:
"""Repräsentant der Menge, zu der ``key`` gehört."""
root = key
while self._parent[root] != root:
root = self._parent[root]
# Pfadverkürzung: alle besuchten Elemente direkt an die Wurzel hängen.
while self._parent[key] != root:
next_key = self._parent[key]
self._parent[key] = root
key = next_key
return root
def union(self, first: int, second: int) -> None:
"""Verschmilzt die Mengen von ``first`` und ``second``."""
root_first = self.find(first)
root_second = self.find(second)
if root_first != root_second:
self._parent[root_second] = root_first
def wake_touched_bodies(contacts: list[Contact]) -> list[Body]:
"""Weckt schlafende Körper, die von einem wachen dynamischen Körper berührt werden.
Mit dem berührten Körper wacht seine ganze Schlaf-Insel auf (siehe
:meth:`Body.wake <sandbox.physics.body.Body.wake>`). Die geweckten Körper gehören ab
sofort zur Insel des wachen Körpers und schlafen erst wieder ein, wenn die ganze Insel
zur Ruhe gekommen ist.
Returns:
Alle in diesem Aufruf geweckten Körper.
"""
woken: list[Body] = []
for contact in contacts:
body_a = contact.body_a
body_b = contact.body_b
if body_a.sleeping == body_b.sleeping:
continue # beide wach, beide schlafend – oder schon von einer Insel geweckt
sleeper, other = (body_a, body_b) if body_a.sleeping else (body_b, body_a)
if other.is_static:
continue # Wände wecken niemanden – sonst schliefe nichts am Boden je ein.
island = sleeper.sleep_island
sleeper.wake()
woken.extend(island if island is not None else (sleeper,))
return woken
def find_islands(bodies: list[Body], contacts: list[Contact]) -> list[list[Body]]:
"""Gruppiert wache dynamische Körper nach Berührung zu Inseln.
Args:
bodies: Die wachen dynamischen Körper.
contacts: Die Kontakte des aktuellen Schritts.
Returns:
Inseln in der Reihenfolge ihres ersten Körpers in ``bodies``.
"""
union_find = UnionFind([body.id for body in bodies])
for contact in contacts:
if contact.body_a.is_static or contact.body_b.is_static:
continue
union_find.union(contact.body_a.id, contact.body_b.id)
islands: dict[int, list[Body]] = {}
for body in bodies:
islands.setdefault(union_find.find(body.id), []).append(body)
return list(islands.values())
def update_sleep(
bodies: list[Body], contacts: list[Contact], dt: float, config: PhysicsConfig
) -> None:
"""Aktualisiert die Ruhezeiten und schickt vollständig ruhende Inseln schlafen.
Args:
bodies: Die wachen dynamischen Körper dieses Schritts.
contacts: Die Kontakte dieses Schritts.
dt: Zeitschritt in s.
config: Schwellwerte für Ruhe und Einschlafzeit.
"""
linear_limit_sq = config.sleep_linear_speed * config.sleep_linear_speed
angular_limit = config.sleep_angular_speed
for body in bodies:
resting = (
not body.held
and body.velocity.length_squared() <= linear_limit_sq
and abs(body.angular_velocity) <= angular_limit
)
body.sleep_time = body.sleep_time + dt if resting else 0.0
for island in find_islands(bodies, contacts):
if all(body.sleep_time >= config.time_to_sleep for body in island):
for body in island:
body.put_to_sleep(island) # alle teilen sich dieselbe Liste
+499
View File
@@ -0,0 +1,499 @@
"""Impulsbasierte Kollisionsauflösung ("Sequential Impulses") und Positionskorrektur.
Bedingungen pro Kontakt
-----------------------
1. **Nichtdurchdringung** entlang der Normalen n: Am Kontaktpunkt dürfen sich die Körper
nicht weiter annähern, ``v_n ≥ 0``. Bei einem echten Aufprall gilt zusätzlich Newtons
Stoßgesetz ``v_n' = −e·v_n`` (e = Restitution).
2. **Reibung** entlang der Tangente t: Das Gleiten ``v_t`` soll verschwinden, der
Reibungsimpuls ist aber nach Coulomb auf ``|P_t| ≤ μ·P_n`` begrenzt. Reicht das nicht,
rutschen die Körper weiter (Gleitreibung).
Herleitung des Kollisionsimpulses
---------------------------------
Relativgeschwindigkeit des Kontaktpunkts (B relativ zu A) mit den Hebelarmen r_A, r_B vom
Schwerpunkt zum Kontaktpunkt und ``ω × r = ω·(−r.y, r.x)``::
v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), v_n = v_rel · n
Ein Impuls ``P = λ·n`` wirkt auf B positiv und auf A negativ (actio = reactio)::
v_A' = v_A − λ·n/m_A ω_A' = ω_A − (r_A × λn)/I_A
v_B' = v_B + λ·n/m_B ω_B' = ω_B + (r_B × λn)/I_B
Eingesetzt ändert sich die Normalgeschwindigkeit linear in λ::
v_n' = v_n + λ·K, K = 1/m_A + 1/m_B + (r_A × n)²/I_A + (r_B × n)²/I_B
Aus ``v_n' = −e·v_n`` folgt ``λ = −(1 + e)·v_n / K``. Der Kehrwert 1/K ist die *effektive
Masse* des Kontakts: die träge Masse, die der Stoß entlang n "spürt". Für die Reibung gilt
dasselbe mit t statt n und dem Ziel ``v_t' = 0``.
In dieser Engine verschwinden die Drehterme von K für die Normale: Bei Kreisen geht die
Normale durch den Mittelpunkt (r ∥ n, also r × n = 0), und Rechtecke drehen sich nie
(1/I = 0). Normalimpulse erzeugen daher kein Drehmoment – nur die Reibung bringt Kreise
zum Rollen.
Rollwiderstand
--------------
Reines Rollen verbraucht keine Reibungsenergie: Ohne weitere Kraft rollte ein Kreis ewig,
und ein Rechteck auf einem Kreis führe darauf wie auf einer Rolle davon. Real verformen
sich Kreis und Unterlage ein wenig, was ein bremsendes Moment ``M ≤ μ_r·N·r`` erzeugt
(μ_r = Rollwiderstandsbeiwert, N = Normalkraft, r = Radius). Pro Schritt entspricht das
einem Drehimpuls ``≤ μ_r·P_n·r``. Umgesetzt ist es wie die Reibung als begrenzter Impuls,
nur auf die relative Winkelgeschwindigkeit ``ω_B − ω_A`` mit der effektiven Masse
``1/(1/I_A + 1/I_B)``. Weil die Grenze mit der Auflagekraft wächst, kommen schwer
belastete "Rollen" schnell zur Ruhe, während ein frei rollender Ball weit rollt.
Warum iterativ?
---------------
Liegt ein Körper auf mehreren anderen (Stapel), beeinflussen sich die Kontakte gegenseitig.
Statt ein Gleichungssystem zu lösen, werden die Kontakte reihum mehrfach aufgelöst
(Gauß-Seidel-Verfahren). Geklemmt wird dabei der über alle Durchläufe *akkumulierte*
Impuls (≥ 0), nicht der einzelne Teilimpuls: Ein späterer Durchlauf darf einen zu großen
früheren Impuls wieder zurücknehmen. So konvergiert das Verfahren gegen die physikalische
Lösung, in der Kontakte nur drücken, aber nie ziehen.
Warm-Starting: Die akkumulierten Impulse des vorigen Schritts sind ein sehr guter
Startwert, weil sich ruhende Kontakte kaum ändern. Ein Stapel steht so schon nach wenigen
Durchläufen stabil, statt jeden Schritt bei 0 beginnen zu müssen.
Leistung: Die inneren Schleifen laufen tausendfach pro Schritt. Deshalb arbeiten sie auf
lokalen Float-Listen statt auf Vektorobjekten; die Formeln sind dieselben wie oben,
komponentenweise ausgeschrieben.
"""
from __future__ import annotations
import math
from typing import TypeAlias
from sandbox.config import PhysicsConfig
from sandbox.physics.body import Body
from sandbox.physics.collision import Contact
from sandbox.physics.vector import Vector2
#: Gespeicherte Impulse eines Kontakts für das Warm-Starting:
#: (Normalimpuls, Reibungsimpuls, Rollwiderstandsimpuls, Normale x, Normale y).
_CachedImpulses: TypeAlias = tuple[float, float, float, float, float]
class _Constraint:
"""Vorberechnete Solver-Daten eines Kontakts (nur Floats, für schnelle Iterationen)."""
__slots__ = (
"contact",
"friction",
"index_a",
"index_b",
"inv_inertia_a",
"inv_inertia_b",
"inv_mass_a",
"inv_mass_b",
"normal_impulse",
"normal_mass",
"normal_x",
"normal_y",
"offset_a_x",
"offset_a_y",
"offset_b_x",
"offset_b_y",
"penetration",
"rolling_impulse",
"rolling_limit",
"rolling_mass",
"tangent_impulse",
"tangent_mass",
"tangent_x",
"tangent_y",
"velocity_bias",
)
contact: Contact
friction: float
index_a: int
index_b: int
inv_inertia_a: float
inv_inertia_b: float
inv_mass_a: float
inv_mass_b: float
normal_impulse: float
normal_mass: float
normal_x: float
normal_y: float
offset_a_x: float
offset_a_y: float
offset_b_x: float
offset_b_y: float
penetration: float
rolling_impulse: float
rolling_limit: float
rolling_mass: float
tangent_impulse: float
tangent_mass: float
tangent_x: float
tangent_y: float
velocity_bias: float
class ContactSolver:
"""Löst Kontakte über Impulse auf und korrigiert verbleibende Durchdringungen.
Ablauf pro Simulationsschritt (gesteuert von :class:`~sandbox.physics.world.World`):
1. :meth:`solve_velocities` – Geschwindigkeiten so ändern, dass kein Kontakt sich weiter
schließt (mit Restitution und Reibung).
2. Die Welt integriert die Positionen mit den neuen Geschwindigkeiten.
3. :meth:`correct_positions` – verbliebene Überlappungen direkt über die Positionen
abbauen, ohne Geschwindigkeiten zu ändern.
"""
def __init__(self, config: PhysicsConfig) -> None:
self._config = config
self._constraints: list[_Constraint] = []
self._bodies: list[Body] = []
self._velocity_x: list[float] = []
self._velocity_y: list[float] = []
self._angular_velocity: list[float] = []
self._start_x: list[float] = []
self._start_y: list[float] = []
# Akkumulierte Impulse des letzten Schritts je Körperpaar (für das Warm-Starting).
self._impulse_cache: dict[tuple[int, int], _CachedImpulses] = {}
def reset(self) -> None:
"""Vergisst alle gespeicherten Impulse (z. B. nachdem die Welt geleert wurde)."""
self._constraints = []
self._bodies = []
self._impulse_cache = {}
# ------------------------------------------------------------------ Geschwindigkeiten
def solve_velocities(self, contacts: list[Contact]) -> None:
"""Berechnet die Kontaktimpulse und schreibt die neuen Geschwindigkeiten zurück.
Nach dem Aufruf enthalten die Kontakte die aufgebrachten Impulse
(``normal_impulse``, ``tangent_impulse``).
"""
self._prepare(contacts)
if self._config.warm_starting:
self._warm_start()
for _ in range(self._config.velocity_iterations):
self._solve_velocity_iteration()
self._store_velocities()
def _prepare(self, contacts: list[Contact]) -> None:
"""Legt die Float-Arbeitskopien an und berechnet die effektiven Massen."""
bodies: list[Body] = []
index_of: dict[int, int] = {}
index_pairs: list[tuple[int, int]] = []
for contact in contacts:
indices = []
for body in (contact.body_a, contact.body_b):
index = index_of.get(body.id)
if index is None:
index = len(bodies)
index_of[body.id] = index
bodies.append(body)
indices.append(index)
index_pairs.append((indices[0], indices[1]))
self._bodies = bodies
self._velocity_x = [body.velocity.x for body in bodies]
self._velocity_y = [body.velocity.y for body in bodies]
self._angular_velocity = [body.angular_velocity for body in bodies]
# Positionen bei der Kollisionserkennung – Bezug für die spätere Positionskorrektur.
self._start_x = [body.position.x for body in bodies]
self._start_y = [body.position.y for body in bodies]
self._constraints = [
self._make_constraint(contact, index_a, index_b)
for contact, (index_a, index_b) in zip(contacts, index_pairs, strict=True)
]
def _make_constraint(self, contact: Contact, index_a: int, index_b: int) -> _Constraint:
"""Berechnet alle Größen eines Kontakts, die sich während der Iterationen nicht ändern.
Läuft für jeden Kontakt in jedem Schritt und rechnet deshalb – wie die Iterationen –
komponentenweise mit Floats statt mit temporären Vektorobjekten.
"""
body_a = contact.body_a
body_b = contact.body_b
normal_x = contact.normal.x
normal_y = contact.normal.y
# Tangente = Normale um 90° gedreht (−n.y, n.x). Ihr Vorzeichen ist beliebig, weil
# der Reibungsimpuls symmetrisch auf [−μ·P_n, +μ·P_n] geklemmt wird.
tangent_x = -normal_y
tangent_y = normal_x
# Hebelarme r_A, r_B: vom jeweiligen Schwerpunkt zum Kontaktpunkt.
point = contact.point
offset_a_x = point.x - body_a.position.x
offset_a_y = point.y - body_a.position.y
offset_b_x = point.x - body_b.position.x
offset_b_y = point.y - body_b.position.y
constraint = _Constraint()
constraint.contact = contact
constraint.index_a = index_a
constraint.index_b = index_b
constraint.normal_x = normal_x
constraint.normal_y = normal_y
constraint.tangent_x = tangent_x
constraint.tangent_y = tangent_y
constraint.offset_a_x = offset_a_x
constraint.offset_a_y = offset_a_y
constraint.offset_b_x = offset_b_x
constraint.offset_b_y = offset_b_y
constraint.inv_mass_a = body_a.inv_mass
constraint.inv_mass_b = body_b.inv_mass
constraint.inv_inertia_a = body_a.inv_inertia
constraint.inv_inertia_b = body_b.inv_inertia
# Effektive Massen 1/K (siehe Moduldokumentation). Für die Normale entfallen die
# Drehterme, weil in dieser Engine stets r × n = 0 oder 1/I = 0 gilt.
inv_mass_sum = body_a.inv_mass + body_b.inv_mass
constraint.normal_mass = 1.0 / inv_mass_sum if inv_mass_sum > 0.0 else 0.0
arm_a = offset_a_x * tangent_y - offset_a_y * tangent_x # r_A × t
arm_b = offset_b_x * tangent_y - offset_b_y * tangent_x # r_B × t
k_tangent = (
inv_mass_sum + body_a.inv_inertia * arm_a * arm_a + body_b.inv_inertia * arm_b * arm_b
)
constraint.tangent_mass = 1.0 / k_tangent if k_tangent > 0.0 else 0.0
# Rollwiderstand (siehe Moduldokumentation): nur, wenn sich mindestens ein Körper
# drehen kann. Als Radius dient der längere Hebelarm eines drehbaren Körpers.
inv_inertia_sum = body_a.inv_inertia + body_b.inv_inertia
if inv_inertia_sum > 0.0:
lever = max(
math.hypot(offset_a_x, offset_a_y) if body_a.can_rotate else 0.0,
math.hypot(offset_b_x, offset_b_y) if body_b.can_rotate else 0.0,
)
constraint.rolling_mass = 1.0 / inv_inertia_sum
constraint.rolling_limit = self._config.rolling_resistance * lever
else:
constraint.rolling_mass = 0.0
constraint.rolling_limit = 0.0
# Materialwerte mischen: Die Reibung als geometrisches Mittel (ein glatter Partner
# macht den Kontakt glatt), die Restitution als Maximum (ein Gummiball springt auch
# von einem Holzklotz ab).
constraint.friction = math.sqrt(body_a.material.friction * body_b.material.friction)
restitution = max(body_a.material.restitution, body_b.material.restitution)
# Abprallen nach Newton: v_n' = −e·v_n. Die Aufprallgeschwindigkeit v_n stammt vom
# Beginn des Schritts – vor Schwerkraft und Warm-Start. Mit der Geschwindigkeit nach
# der Schwerkraft prallte ein Ball mit e·(v + g·dt) statt e·v ab und gewänne bei
# e = 1 mit jedem Aufprall Höhe (Energie aus dem Nichts).
velocity_a = body_a.previous_velocity
velocity_b = body_b.previous_velocity
normal_speed = (velocity_b.x - velocity_a.x) * normal_x + (
velocity_b.y - velocity_a.y
) * normal_y
# Kein Abprallen, (1) unterhalb der Schwelle: Aufliegende Körper würden sonst durch
# winzige Dauer-Hüpfer zittern; (2) wenn ein Körper gehalten wird: Die Hand fängt
# den Stoß ab. Sonst prallte ein gegen ein Hindernis gedrückter Körper in jedem
# Schritt ab und würde gleich wieder hingezogen – er zitterte.
bounces = normal_speed < -self._config.restitution_threshold
if bounces and not (body_a.held or body_b.held):
constraint.velocity_bias = -restitution * normal_speed
else:
constraint.velocity_bias = 0.0
constraint.normal_impulse = 0.0
constraint.tangent_impulse = 0.0
constraint.rolling_impulse = 0.0
constraint.penetration = contact.penetration
return constraint
def _warm_start(self) -> None:
"""Wendet die Impulse des letzten Schritts erneut an (bessere Startwerte)."""
cache = self._impulse_cache
min_alignment = self._config.warm_start_min_alignment
velocity_x = self._velocity_x
velocity_y = self._velocity_y
angular = self._angular_velocity
for c in self._constraints:
cached = cache.get(c.contact.key)
if cached is None:
continue
normal_impulse, tangent_impulse, rolling_impulse, old_normal_x, old_normal_y = cached
if old_normal_x * c.normal_x + old_normal_y * c.normal_y < min_alignment:
continue # Normale hat sich zu stark gedreht, der alte Impuls passt nicht.
c.normal_impulse = normal_impulse
c.tangent_impulse = tangent_impulse
c.rolling_impulse = rolling_impulse
impulse_x = normal_impulse * c.normal_x + tangent_impulse * c.tangent_x
impulse_y = normal_impulse * c.normal_y + tangent_impulse * c.tangent_y
a = c.index_a
b = c.index_b
velocity_x[a] -= impulse_x * c.inv_mass_a
velocity_y[a] -= impulse_y * c.inv_mass_a
angular[a] -= c.inv_inertia_a * (
c.offset_a_x * impulse_y - c.offset_a_y * impulse_x + rolling_impulse
)
velocity_x[b] += impulse_x * c.inv_mass_b
velocity_y[b] += impulse_y * c.inv_mass_b
angular[b] += c.inv_inertia_b * (
c.offset_b_x * impulse_y - c.offset_b_y * impulse_x + rolling_impulse
)
def _solve_velocity_iteration(self) -> None:
"""Ein Gauß-Seidel-Durchlauf über alle Kontakte.
Reihenfolge je Kontakt: Normale, dann Reibung, dann Rollwiderstand. Reibung und
Rollwiderstand sind durch den Normalimpuls begrenzt; werden sie danach gelöst, gilt
ihre Grenze nach jedem Durchlauf exakt für den aktuellen Normalimpuls. (Andersherum
könnte der Normalimpuls hinterher noch sinken und die Reibung über der
Coulomb-Grenze zurücklassen.)
"""
velocity_x = self._velocity_x
velocity_y = self._velocity_y
angular = self._angular_velocity
for c in self._constraints:
a = c.index_a
b = c.index_b
# --- Normale: Drehterme entfallen hier (siehe Moduldokumentation).
normal_speed = (velocity_x[b] - velocity_x[a]) * c.normal_x + (
velocity_y[b] - velocity_y[a]
) * c.normal_y
# λ = (Ziel − v_n)/K; Ziel ist die Abprallgeschwindigkeit (oder 0).
old_impulse = c.normal_impulse
new_impulse = old_impulse + (c.velocity_bias - normal_speed) * c.normal_mass
if new_impulse < 0.0:
new_impulse = 0.0 # Kontakte können nur drücken, nie ziehen.
c.normal_impulse = new_impulse
delta = new_impulse - old_impulse
impulse_x = delta * c.normal_x
impulse_y = delta * c.normal_y
velocity_x[a] -= impulse_x * c.inv_mass_a
velocity_y[a] -= impulse_y * c.inv_mass_a
velocity_x[b] += impulse_x * c.inv_mass_b
velocity_y[b] += impulse_y * c.inv_mass_b
# --- Reibung: v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), ω × r = (−ω·r.y, ω·r.x)
rel_x = (velocity_x[b] - angular[b] * c.offset_b_y) - (
velocity_x[a] - angular[a] * c.offset_a_y
)
rel_y = (velocity_y[b] + angular[b] * c.offset_b_x) - (
velocity_y[a] + angular[a] * c.offset_a_x
)
tangent_speed = rel_x * c.tangent_x + rel_y * c.tangent_y
# λ so, dass das Gleiten verschwindet; danach Coulomb-Grenze auf den Gesamtimpuls.
max_friction = c.friction * c.normal_impulse
old_impulse = c.tangent_impulse
new_impulse = old_impulse - tangent_speed * c.tangent_mass
if new_impulse > max_friction:
new_impulse = max_friction
elif new_impulse < -max_friction:
new_impulse = -max_friction
c.tangent_impulse = new_impulse
delta = new_impulse - old_impulse
impulse_x = delta * c.tangent_x
impulse_y = delta * c.tangent_y
velocity_x[a] -= impulse_x * c.inv_mass_a
velocity_y[a] -= impulse_y * c.inv_mass_a
angular[a] -= c.inv_inertia_a * (c.offset_a_x * impulse_y - c.offset_a_y * impulse_x)
velocity_x[b] += impulse_x * c.inv_mass_b
velocity_y[b] += impulse_y * c.inv_mass_b
angular[b] += c.inv_inertia_b * (c.offset_b_x * impulse_y - c.offset_b_y * impulse_x)
# --- Rollwiderstand: bremst die relative Drehung, höchstens μ_r·P_n·r.
if c.rolling_mass > 0.0:
max_rolling = c.rolling_limit * c.normal_impulse
old_impulse = c.rolling_impulse
new_impulse = old_impulse - (angular[b] - angular[a]) * c.rolling_mass
if new_impulse > max_rolling:
new_impulse = max_rolling
elif new_impulse < -max_rolling:
new_impulse = -max_rolling
c.rolling_impulse = new_impulse
delta = new_impulse - old_impulse
angular[a] -= c.inv_inertia_a * delta
angular[b] += c.inv_inertia_b * delta
def _store_velocities(self) -> None:
"""Überträgt die Ergebnisse auf Körper und Kontakte und merkt sich die Impulse."""
for index, body in enumerate(self._bodies):
if body.is_static:
continue
body.velocity = Vector2(self._velocity_x[index], self._velocity_y[index])
body.angular_velocity = self._angular_velocity[index]
cache: dict[tuple[int, int], _CachedImpulses] = {}
for c in self._constraints:
contact = c.contact
contact.normal_impulse = c.normal_impulse
contact.tangent_impulse = c.tangent_impulse
cache[contact.key] = (
c.normal_impulse,
c.tangent_impulse,
c.rolling_impulse,
c.normal_x,
c.normal_y,
)
self._impulse_cache = cache
# ------------------------------------------------------------------ Positionen
def correct_positions(self) -> None:
"""Baut verbliebene Durchdringungen der zuletzt gelösten Kontakte ab.
Warum überhaupt? Impulse korrigieren nur Geschwindigkeiten. Durch den diskreten
Zeitschritt überlappen Körper trotzdem ein wenig (ein fallender Körper "landet"
meist schon etwas in der Unterlage), und ohne Korrektur würden Stapel langsam
ineinander einsinken.
Warum über Positionen statt über Zusatzgeschwindigkeit? Eine Geschwindigkeit, die
Körper auseinandertreibt, bleibt nach der Trennung erhalten und schleudert sie
davon – besonders bei großen Überlappungen, etwa wenn ein Objekt mitten in einen
Haufen gesetzt wird. Die direkte Verschiebung ("Projektion") erzeugt dagegen keine
Bewegungsenergie.
Verschoben wird im Verhältnis der inversen Massen – der leichtere Körper weicht
weiter aus, eine Wand gar nicht. Die kleine Toleranz ``penetration_slop`` bleibt
stehen, damit der Kontakt im nächsten Schritt sicher wieder erkannt wird.
"""
constraints = self._constraints
if not constraints:
return
config = self._config
slop = config.penetration_slop
factor = config.position_correction_factor
max_correction = config.max_position_correction
bodies = self._bodies
position_x = [body.position.x for body in bodies]
position_y = [body.position.y for body in bodies]
start_x = self._start_x
start_y = self._start_y
for _ in range(config.position_iterations):
for c in constraints:
a = c.index_a
b = c.index_b
# Aktuelle Tiefe = Tiefe bei der Erkennung minus die seither erfolgte
# Relativbewegung entlang der Normalen. Für Wände und Rechtecke ist das exakt
# (keine Drehung), für Kreise eine sehr gute Näherung – und viel billiger als
# eine erneute Kollisionserkennung.
moved_x = (position_x[b] - start_x[b]) - (position_x[a] - start_x[a])
moved_y = (position_y[b] - start_y[b]) - (position_y[a] - start_y[a])
penetration = c.penetration - (moved_x * c.normal_x + moved_y * c.normal_y)
correction = (penetration - slop) * factor
if correction <= 0.0:
continue
correction = min(correction, max_correction)
# Aufteilen im Verhältnis der inversen Massen (Summe > 0, da nie zwei
# statische Körper einen Kontakt bilden).
share = correction / (c.inv_mass_a + c.inv_mass_b)
shift_a = share * c.inv_mass_a
shift_b = share * c.inv_mass_b
position_x[a] -= c.normal_x * shift_a
position_y[a] -= c.normal_y * shift_a
position_x[b] += c.normal_x * shift_b
position_y[b] += c.normal_y * shift_b
for index, body in enumerate(bodies):
if body.is_static:
continue
new_x = position_x[index]
new_y = position_y[index]
if new_x != body.position.x or new_y != body.position.y:
body.position = Vector2(new_x, new_y)
+69
View File
@@ -0,0 +1,69 @@
"""Fester Zeitschritt, entkoppelt von der Bildrate ("Fixed Timestep").
Warum: Die Stabilität der Integration hängt vom Zeitschritt ab. Würde die Physik einfach
die (schwankende) Frame-Dauer als dt verwenden, verhielte sich die Simulation auf
schnellen und langsamen Rechnern unterschiedlich, und ein einzelner Ruckler (z. B. 200 ms
beim Verschieben des Fensters) ließe Körper durcheinander hindurchspringen.
Stattdessen sammelt ein Akkumulator die real vergangene Zeit und gibt sie in festen
Portionen ``dt`` an die Physik weiter. Der Rest bleibt für den nächsten Frame liegen; sein
Anteil an dt (``alpha``) erlaubt es, beim Zeichnen zwischen den letzten beiden
Physikzuständen zu interpolieren, damit die Bewegung trotzdem flüssig aussieht.
"""
from __future__ import annotations
import math
from sandbox.config import NUMERIC_EPSILON
class FixedTimestep:
"""Akkumulator, der Frame-Zeiten in eine Anzahl fester Physikschritte übersetzt.
Args:
step: Fester Zeitschritt in s.
max_steps_per_frame: Obergrenze der Schritte pro Frame. Schafft der Rechner die
Physik nicht in Echtzeit, würde der Rückstand sonst immer weiter wachsen
("Spiral of Death"). Mit Obergrenze läuft die Simulation dann nur langsamer.
"""
def __init__(self, step: float, max_steps_per_frame: int) -> None:
if not step > 0.0:
raise ValueError(f"Der Zeitschritt muss positiv sein: {step}")
if max_steps_per_frame < 1:
raise ValueError("Es muss mindestens ein Schritt pro Frame erlaubt sein")
self.step = step
self.max_steps_per_frame = max_steps_per_frame
self._accumulator = 0.0
@property
def alpha(self) -> float:
"""Anteil des angebrochenen Schritts in [0, 1) – Gewicht für die Interpolation."""
return self._accumulator / self.step
def advance(self, frame_time: float) -> int:
"""Nimmt die Dauer eines Frames entgegen und liefert die Zahl fälliger Schritte.
Args:
frame_time: Real vergangene Zeit seit dem letzten Aufruf in s.
Returns:
Anzahl der jetzt auszuführenden Physikschritte.
"""
if frame_time < 0.0:
raise ValueError(f"Die Frame-Zeit darf nicht negativ sein: {frame_time}")
self._accumulator += frame_time
# Das kleine Epsilon fängt Rundungsfehler ab: 2·(1/120) ergibt als Gleitkommazahl
# nicht exakt 1/60, und ohne Toleranz ginge gelegentlich ein Schritt "verloren".
steps = math.floor(self._accumulator / self.step + NUMERIC_EPSILON)
if steps > self.max_steps_per_frame:
steps = self.max_steps_per_frame
self._accumulator = 0.0 # Rückstand verwerfen statt ihn endlos mitzuschleppen.
else:
self._accumulator = max(0.0, self._accumulator - steps * self.step)
return steps
def reset(self) -> None:
"""Verwirft angesammelte Zeit (z. B. beim Pausieren)."""
self._accumulator = 0.0
+162
View File
@@ -0,0 +1,162 @@
"""Eigene 2D-Vektorklasse der Physik-Schicht.
Die Physik nutzt bewusst weder ``pygame.Vector2`` noch externe Mathematik-Bibliotheken,
sondern nur diese Klasse und das Standardmodul :mod:`math`. Dadurch hängt die Physik nicht
von einer Grafikbibliothek ab und lässt sich isoliert testen.
"""
from __future__ import annotations
import math
from typing import Final
from sandbox.config import NUMERIC_EPSILON
# ``object.__setattr__`` einmal nachschlagen statt bei jedem Konstruktoraufruf: Vektoren
# entstehen tausendfach pro Simulationsschritt, und das normale Setzen ist gesperrt (s. u.).
_set_slot = object.__setattr__
class Vector2:
"""Unveränderlicher zweidimensionaler Vektor.
Jede Operation liefert einen neuen Vektor, kein Operand wird verändert. Vektoren
verhalten sich damit wie Zahlen: Ein Körper kann seine Position herausgeben, ohne dass
jemand sie über die geteilte Referenz versehentlich mitverändert. Zuweisungen wie
``v.x = 1.0`` lösen deshalb einen :class:`AttributeError` aus.
Attributes:
x: x-Komponente.
y: y-Komponente (zeigt wie auf dem Bildschirm nach unten).
"""
__slots__ = ("x", "y")
x: float
y: float
def __init__(self, x: float = 0.0, y: float = 0.0) -> None:
_set_slot(self, "x", x)
_set_slot(self, "y", y)
# ------------------------------------------------------------------ Unveränderlichkeit
def __setattr__(self, name: str, value: object) -> None:
raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gesetzt werden)")
def __delattr__(self, name: str) -> None:
raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gelöscht werden)")
def __reduce__(self) -> tuple[type[Vector2], tuple[float, float]]:
# copy und pickle würden die Slots sonst per setattr befüllen – das ist gesperrt.
return (Vector2, (self.x, self.y))
# ------------------------------------------------------------------ Arithmetik
def __add__(self, other: Vector2) -> Vector2:
return Vector2(self.x + other.x, self.y + other.y)
def __sub__(self, other: Vector2) -> Vector2:
return Vector2(self.x - other.x, self.y - other.y)
def __mul__(self, scalar: float) -> Vector2:
return Vector2(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar: float) -> Vector2:
return Vector2(self.x * scalar, self.y * scalar)
def __truediv__(self, scalar: float) -> Vector2:
return Vector2(self.x / scalar, self.y / scalar)
def __neg__(self) -> Vector2:
return Vector2(-self.x, -self.y)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector2):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self) -> int:
return hash((self.x, self.y))
def __repr__(self) -> str:
return f"Vector2({self.x!r}, {self.y!r})"
# ------------------------------------------------------------------ Vektoroperationen
def dot(self, other: Vector2) -> float:
"""Skalarprodukt ``a·b = a.x·b.x + a.y·b.y``.
Liefert die Länge der Projektion von ``other`` auf diesen Vektor (mal dessen Länge).
Mit einer Einheitsnormalen n misst ``v.dot(n)`` also, wie schnell sich etwas
entlang n bewegt.
"""
return self.x * other.x + self.y * other.y
def cross(self, other: Vector2) -> float:
"""z-Komponente des Kreuzprodukts ``a × b = a.x·b.y − a.y·b.x``.
In 2D ist das Kreuzprodukt ein Skalar. Physikalisch ist ``r × F`` das Drehmoment,
das eine Kraft (bzw. ein Impuls) F am Hebelarm r erzeugt.
"""
return self.x * other.y - self.y * other.x
def length(self) -> float:
"""Euklidische Länge (Betrag) des Vektors."""
return math.hypot(self.x, self.y)
def length_squared(self) -> float:
"""Quadrat der Länge – spart die Wurzel, wenn nur Längen verglichen werden."""
return self.x * self.x + self.y * self.y
def normalized(self) -> Vector2:
"""Einheitsvektor gleicher Richtung.
Der Nullvektor hat keine Richtung. Statt eine Division durch 0 (und damit NaN-Werte,
die sich durch die ganze Simulation fressen würden) zu riskieren, wird dann der
Nullvektor zurückgegeben; der Aufrufer entscheidet über eine Ersatzrichtung.
"""
length = math.hypot(self.x, self.y)
if length <= NUMERIC_EPSILON:
return Vector2(0.0, 0.0)
return Vector2(self.x / length, self.y / length)
def perpendicular(self) -> Vector2:
"""Um +90° gedrehter Vektor ``(−y, x)``.
Nützlich für Tangenten und für ``ω × r``: Die Bahngeschwindigkeit eines Punktes im
Abstand r eines mit ω drehenden Körpers ist ``ω · r.perpendicular()``.
"""
return Vector2(-self.y, self.x)
def distance_to(self, other: Vector2) -> float:
"""Abstand zwischen zwei Punkten."""
return math.hypot(other.x - self.x, other.y - self.y)
def clamped(self, max_length: float) -> Vector2:
"""Vektor gleicher Richtung, dessen Länge höchstens ``max_length`` beträgt."""
length_sq = self.x * self.x + self.y * self.y
if length_sq <= max_length * max_length:
return self
scale = max_length / math.sqrt(length_sq)
return Vector2(self.x * scale, self.y * scale)
def lerp(self, other: Vector2, t: float) -> Vector2:
"""Lineare Interpolation: ``t = 0`` liefert ``self``, ``t = 1`` liefert ``other``."""
return Vector2(self.x + (other.x - self.x) * t, self.y + (other.y - self.y) * t)
def is_finite(self) -> bool:
"""True, wenn keine Komponente NaN oder unendlich ist."""
return math.isfinite(self.x) and math.isfinite(self.y)
def is_close(self, other: Vector2, tolerance: float = NUMERIC_EPSILON) -> bool:
"""Komponentenweiser Vergleich mit absoluter Toleranz."""
return abs(self.x - other.x) <= tolerance and abs(self.y - other.y) <= tolerance
def to_tuple(self) -> tuple[float, float]:
"""Komponenten als Tupel ``(x, y)``, z. B. für Zeichenfunktionen."""
return (self.x, self.y)
#: Der Nullvektor. Da Vektoren unveränderlich sind, darf er überall geteilt werden.
ZERO: Final = Vector2(0.0, 0.0)
+280
View File
@@ -0,0 +1,280 @@
"""Die Physikwelt: verwaltet Körper und Wände und führt Simulationsschritte aus."""
from __future__ import annotations
import math
from sandbox.config import PhysicsConfig
from sandbox.physics.body import Body, Material
from sandbox.physics.broadphase import find_candidate_pairs
from sandbox.physics.collision import Contact, collide
from sandbox.physics.sleep import update_sleep, wake_touched_bodies
from sandbox.physics.solver import ContactSolver
from sandbox.physics.vector import ZERO, Vector2
class World:
"""Rechteckige Welt mit Wänden an allen vier Seiten.
Koordinaten: Ursprung oben links, x nach rechts, y nach unten, Einheit px. Die Welt
kennt keinen Bildschirm – wo sie gezeichnet wird, entscheidet die Anwendung.
Ein Aufruf von :meth:`step` rückt die Simulation um einen festen Zeitschritt vor
(semi-impliziter Euler mit Kontaktauflösung, Details in :meth:`step`).
Args:
width: Breite der Welt in px.
height: Höhe der Welt in px.
config: Physikparameter.
Attributes:
width: Breite der Welt in px.
height: Höhe der Welt in px.
config: Physikparameter.
bodies: Alle dynamischen Körper in Einfügereihenfolge (später eingefügte liegen beim
Zeichnen oben).
walls: Die vier statischen Wände (links, rechts, oben, unten).
contacts: Die Kontakte des letzten Schritts (für die Debug-Anzeige).
step_count: Anzahl bisher ausgeführter Schritte.
"""
def __init__(self, width: float, height: float, config: PhysicsConfig) -> None:
if not (width > 0.0 and height > 0.0):
raise ValueError(f"Die Welt braucht eine positive Größe: {width} x {height}")
self.width = width
self.height = height
self.config = config
self.bodies: list[Body] = []
self.contacts: list[Contact] = []
self.step_count = 0
self._gravity_enabled = True
self._solver = ContactSolver(config)
wall_material = Material(
density=0.0, restitution=config.wall_restitution, friction=config.wall_friction
)
# Jede Wandnormale zeigt in die Welt hinein; frei ist die Seite normal·p ≥ offset.
self.walls: list[Body] = [
Body.wall(Vector2(1.0, 0.0), 0.0, wall_material), # links: x ≥ 0
Body.wall(Vector2(-1.0, 0.0), -width, wall_material), # rechts: x ≤ width
Body.wall(Vector2(0.0, 1.0), 0.0, wall_material), # oben: y ≥ 0
Body.wall(Vector2(0.0, -1.0), -height, wall_material), # unten: y ≤ height
]
# ------------------------------------------------------------------ Schwerkraft
@property
def gravity_enabled(self) -> bool:
"""Ob die Schwerkraft wirkt."""
return self._gravity_enabled
@gravity_enabled.setter
def gravity_enabled(self, enabled: bool) -> None:
if enabled != self._gravity_enabled:
self._gravity_enabled = enabled
# Schlafende Körper würden die Änderung sonst nicht bemerken und z. B. ohne
# Schwerkraft am Boden kleben bleiben bzw. mit Schwerkraft in der Luft hängen.
self.wake_all()
@property
def gravity(self) -> Vector2:
"""Aktuell wirkende Schwerkraftbeschleunigung (Nullvektor, wenn abgeschaltet)."""
if not self._gravity_enabled:
return ZERO
return Vector2(*self.config.gravity)
# ------------------------------------------------------------------ Körperverwaltung
def add_body(self, body: Body) -> Body:
"""Fügt einen dynamischen Körper hinzu und gibt ihn zurück."""
if body.is_static:
raise ValueError("Statische Körper werden von der Welt selbst verwaltet (Wände)")
self.bodies.append(body)
return body
def remove_body(self, body: Body) -> None:
"""Entfernt einen Körper und weckt alles, was auf ihm gelegen haben könnte.
Dafür genügt es, seine Schlaf-Insel zu wecken: Schlafende Körper, die einander
berühren, sind immer gemeinsam eingeschlafen und gehören damit zur selben Insel.
"""
self.bodies.remove(body)
body.wake()
def clear(self) -> None:
"""Entfernt alle Körper."""
self.bodies.clear()
self.contacts = []
self._solver.reset()
def wake_all(self) -> None:
"""Weckt alle Körper auf."""
for body in self.bodies:
body.wake()
def body_at(self, point: Vector2, tolerance: float = 0.0) -> Body | None:
"""Oberster Körper an einem Punkt (später eingefügte Körper liegen oben).
Args:
point: Punkt in Weltkoordinaten.
tolerance: Vergrößert die Körper beim Test, damit kleine Objekte leichter zu
treffen sind.
"""
for body in reversed(self.bodies):
if body.contains_point(point, tolerance):
return body
return None
def clamp_inside(self, position: Vector2, half_extents: tuple[float, float]) -> Vector2:
"""Verschiebt eine Mittelpunktsposition so, dass ein Körper der Größe ganz hineinpasst."""
half_x, half_y = half_extents
x = min(max(position.x, half_x), self.width - half_x)
y = min(max(position.y, half_y), self.height - half_y)
if x == position.x and y == position.y:
return position
return Vector2(x, y)
# ------------------------------------------------------------------ Simulation
def step(self, dt: float) -> None:
"""Rückt die Simulation um ``dt`` Sekunden vor.
Ablauf (semi-impliziter Euler mit Kontaktauflösung):
1. Zustand merken: Position und Winkel für die Zeichen-Interpolation, die
Geschwindigkeit für die Aufprallgeschwindigkeit beim Abprallen.
2. Geschwindigkeiten integrieren: ``v += g·dt`` (plus Dämpfung).
3. Kontakte suchen: Sweep-and-Prune, dann genaue Tests; schlafende Körper, die
berührt werden, samt ihrer Schlaf-Insel wecken; danach Wandkontakte der wachen
Körper.
4. Impulse lösen (Restitution, Reibung, Rollwiderstand) und Geschwindigkeiten
begrenzen.
5. Positionen integrieren: ``x += v·dt`` – mit der *neuen* Geschwindigkeit. Genau
das macht den Euler "semi-implizit" (symplektisch): Anders als beim expliziten
Euler wächst die Energie dabei nicht systematisch an, Umlaufbahnen und
Schwingungen bleiben stabil.
6. Verbliebene Durchdringungen über die Positionen abbauen.
7. Notbremse: kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand.
8. Schlafmodus aktualisieren.
"""
awake = [body for body in self.bodies if not body.sleeping]
self._remember_state(awake)
self._integrate_velocities(awake, dt)
contacts = self._find_body_contacts()
woken = wake_touched_bodies(contacts)
self._remember_state(woken)
awake.extend(woken)
contacts.extend(self._find_wall_contacts(awake))
self._solver.solve_velocities(contacts)
self._clamp_velocities(awake)
self._integrate_positions(awake, dt)
self._solver.correct_positions()
self._enforce_bounds(awake)
if self.config.sleep_enabled:
update_sleep(awake, contacts, dt, self.config)
self.contacts = contacts
self.step_count += 1
@staticmethod
def _remember_state(bodies: list[Body]) -> None:
"""Merkt sich den Zustand zu Beginn des Schritts (siehe ``Body.previous_*``)."""
for body in bodies:
body.previous_position = body.position
body.previous_angle = body.angle
body.previous_velocity = body.velocity
def _integrate_velocities(self, bodies: list[Body], dt: float) -> None:
"""Wendet Schwerkraft und Dämpfung auf die Geschwindigkeiten an."""
gravity = self.gravity
gravity_dx = gravity.x * dt
gravity_dy = gravity.y * dt
# Dämpfung als v ← v / (1 + c·dt): Näherung von v·e^(−c·dt), die anders als
# v·(1 − c·dt) auch bei großem c·dt nie das Vorzeichen umkehrt.
linear_factor = 1.0 / (1.0 + dt * self.config.linear_damping)
angular_factor = 1.0 / (1.0 + dt * self.config.angular_damping)
for body in bodies:
if body.held:
continue # Die Geschwindigkeit gehaltener Körper gibt die Maus vor.
velocity = body.velocity
body.velocity = Vector2(
(velocity.x + gravity_dx) * linear_factor,
(velocity.y + gravity_dy) * linear_factor,
)
body.angular_velocity *= angular_factor
def _find_body_contacts(self) -> list[Contact]:
"""Kontakte zwischen dynamischen Körpern (grobe Vorauswahl, dann genauer Test)."""
contacts: list[Contact] = []
for body_a, body_b in find_candidate_pairs(self.bodies):
contact = collide(body_a, body_b)
if contact is not None:
contacts.append(contact)
return contacts
def _find_wall_contacts(self, bodies: list[Body]) -> list[Contact]:
"""Kontakte der wachen Körper mit den Wänden."""
left, right, top, bottom = self.walls
width = self.width
height = self.height
contacts: list[Contact] = []
for body in bodies:
min_x, min_y, max_x, max_y = body.bounds()
# Billiger Vortest über die umschließende Box, genauer Test nur bei Bedarf.
for touches, wall in (
(min_x <= 0.0, left),
(max_x >= width, right),
(min_y <= 0.0, top),
(max_y >= height, bottom),
):
if touches:
contact = collide(body, wall)
if contact is not None:
contacts.append(contact)
return contacts
def _clamp_velocities(self, bodies: list[Body]) -> None:
"""Begrenzt Geschwindigkeit und Drehung (Schutz vor Tunneln und Explosionen)."""
max_speed = self.config.max_speed
max_speed_sq = max_speed * max_speed
max_angular = self.config.max_angular_speed
for body in bodies:
velocity = body.velocity
speed_sq = velocity.length_squared()
if speed_sq > max_speed_sq:
body.velocity = velocity * (max_speed / math.sqrt(speed_sq))
if abs(body.angular_velocity) > max_angular:
body.angular_velocity = math.copysign(max_angular, body.angular_velocity)
def _integrate_positions(self, bodies: list[Body], dt: float) -> None:
"""``x += v·dt`` und ``θ += ω·dt`` mit den bereits aktualisierten Geschwindigkeiten."""
full_turn = math.tau
for body in bodies:
velocity = body.velocity
position = body.position
body.position = Vector2(position.x + velocity.x * dt, position.y + velocity.y * dt)
if body.angular_velocity != 0.0:
# Winkel im Bereich [−π, π] halten, damit er über lange Zeit nicht an
# Genauigkeit verliert.
body.angle = math.remainder(body.angle + body.angular_velocity * dt, full_turn)
def _enforce_bounds(self, bodies: list[Body]) -> None:
"""Notbremse: Kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand.
Im Normalfall halten die Wandkontakte die Körper in der Welt. Die Grenze liegt über
der größten Eindringtiefe eines frei fliegenden Körpers pro Schritt und greift daher
nur in Extremfällen, etwa wenn ein Körper von einem gezogenen Objekt gegen die Wand
gequetscht wird. Geschwindigkeiten bleiben unangetastet: Den eigentlichen Stoß mit
der Wand löst im nächsten Schritt der Solver.
"""
allowed = self.config.max_wall_penetration
for body in bodies:
half_x, half_y = body.half_extents()
position = body.position
x = min(max(position.x, half_x - allowed), self.width - half_x + allowed)
y = min(max(position.y, half_y - allowed), self.height - half_y + allowed)
if x != position.x or y != position.y:
body.position = Vector2(x, y)
+243
View File
@@ -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)
)
+1
View File
@@ -0,0 +1 @@
"""Bedienoberfläche: Widgets und die Werkzeugleiste (Sidebar)."""
+65
View File
@@ -0,0 +1,65 @@
"""Zeichenhelfer für Körperformen – gemeinsam genutzt von Renderer und Vorschau-Kachel."""
from __future__ import annotations
import math
from typing import TypeAlias
import pygame
#: RGB- oder RGBA-Farbe (RGBA für halbtransparente Vorschauen).
DrawColor: TypeAlias = tuple[int, ...]
def draw_circle(
surface: pygame.Surface,
color: DrawColor,
outline: DrawColor,
center: tuple[float, float],
radius: float,
*,
outline_width: int,
angle: float | None = None,
marker_length: float = 0.0,
) -> None:
"""Zeichnet einen geglätteten Kreis mit Umrandung und optionalem Drehmarker.
Args:
surface: Zielfläche.
color: Füllfarbe.
outline: Farbe von Umrandung und Drehmarker.
center: Mittelpunkt in Bildschirmkoordinaten.
radius: Radius in px.
outline_width: Linienstärke der Umrandung.
angle: Drehwinkel in rad. Ohne Marker wäre die Drehung eines einfarbigen Kreises
unsichtbar – man sähe nicht, ob er rollt oder rutscht.
marker_length: Länge des Markers als Anteil des Radius.
"""
# Die Füllung braucht keine Kantenglättung: Die geglättete Umrandung liegt genau auf
# ihrem Rand und verdeckt die Treppenstufen. Das halbiert die teuren aacircle-Aufrufe.
pygame.draw.circle(surface, color, center, radius)
pygame.draw.aacircle(surface, outline, center, radius, outline_width)
if angle is not None:
end = (
center[0] + math.cos(angle) * radius * marker_length,
center[1] + math.sin(angle) * radius * marker_length,
)
pygame.draw.line(surface, outline, center, end, outline_width)
def draw_box(
surface: pygame.Surface,
color: DrawColor,
outline: DrawColor,
center: tuple[float, float],
half_extents: tuple[float, float],
*,
outline_width: int,
corner_radius: int = 0,
) -> None:
"""Zeichnet ein achsenparalleles Rechteck mit Umrandung."""
half_width, half_height = half_extents
rect = pygame.Rect(0, 0, round(2 * half_width), round(2 * half_height))
rect.center = (round(center[0]), round(center[1]))
pygame.draw.rect(surface, color, rect, border_radius=corner_radius)
pygame.draw.rect(surface, outline, rect, outline_width, border_radius=corner_radius)
+298
View File
@@ -0,0 +1,298 @@
"""Werkzeugleiste am linken Fensterrand. Sie gehört nicht zur Physikwelt."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
from typing import Protocol
import pygame
from sandbox.config import Color, InteractionConfig
from sandbox.interaction import ShapeKind, ToolSettings
from sandbox.ui.style import UiStyle
from sandbox.ui.widgets import (
Button,
ColorPicker,
Label,
LabelKind,
SegmentedControl,
Slider,
SpawnTile,
Widget,
)
class SidebarControls(Protocol):
"""Was die Sidebar von der Anwendung braucht (erfüllt von :class:`~sandbox.main.App`)."""
def is_paused(self) -> bool:
"""Ob die Simulation pausiert ist."""
...
def toggle_pause(self) -> None:
"""Pausiert bzw. setzt fort."""
...
def gravity_enabled(self) -> bool:
"""Ob die Schwerkraft wirkt."""
...
def toggle_gravity(self) -> None:
"""Schaltet die Schwerkraft um."""
...
def clear_world(self) -> None:
"""Entfernt alle Körper."""
...
def begin_spawn_drag(self) -> None:
"""Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel."""
...
def preview_color(self) -> Color:
"""Farbe des nächsten Objekts."""
...
def status_lines(self) -> list[str]:
"""Statuszeilen für den unteren Rand (Objektzahl, FPS …)."""
...
def _format_size(value: float) -> str:
return f"{value:.0f} px"
def _format_ratio(value: float) -> str:
return f"{value:.2f}"
def _format_density(value: float) -> str:
return f"{value:.1f}"
class Sidebar:
"""Baut die Bedienelemente auf, verteilt Events an sie und zeichnet sie.
Args:
rect: Bereich der Sidebar auf dem Bildschirm.
style: Farben, Schriften, Maße.
tools: Die Werkzeug-Einstellungen, die die Regler verändern.
limits: Wertebereiche der Regler.
palette: Farbpalette.
controls: Anbindung an die Anwendung.
"""
def __init__(
self,
rect: pygame.Rect,
style: UiStyle,
tools: ToolSettings,
limits: InteractionConfig,
palette: tuple[Color, ...],
controls: SidebarControls,
) -> None:
self.rect = pygame.Rect(rect)
self.style = style
self.tools = tools
self.controls = controls
self.widgets: list[Widget] = []
self._status_top = 0
self._build(limits, palette)
# ------------------------------------------------------------------ Aufbau
def _build(self, limits: InteractionConfig, palette: tuple[Color, ...]) -> None:
"""Ordnet die Widgets von oben nach unten an."""
ui = self.style.ui
tools = self.tools
controls = self.controls
left = self.rect.x + ui.padding
width = self.rect.width - 2 * ui.padding
y = self.rect.y + ui.padding
def row(height: int) -> pygame.Rect:
return pygame.Rect(left, y, width, height)
def add(widget: Widget, gap: int = ui.row_gap) -> None:
nonlocal y
self.widgets.append(widget)
y = widget.rect.bottom + gap
def slider(
name: str,
label: str,
value_format: Callable[[float], str],
*,
minimum: float,
maximum: float,
step: float,
is_enabled: Callable[[], bool] | None = None,
) -> Slider:
# Der Regler liest und schreibt direkt das gleichnamige Attribut der Einstellungen.
return Slider(
row(ui.slider_height),
label,
minimum=minimum,
maximum=maximum,
step=step,
get_value=partial(getattr, tools, name),
set_value=partial(setattr, tools, name),
value_format=value_format,
knob_radius=ui.slider_knob_radius,
is_enabled=is_enabled,
name=name,
)
add(Label(row(ui.header_height + ui.padding), "Physik-Sandbox", LabelKind.TITLE))
add(Label(row(ui.header_height), "OBJEKT", LabelKind.HEADER))
add(
SegmentedControl(
row(ui.button_height),
[(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")],
partial(getattr, tools, "shape"),
partial(setattr, tools, "shape"),
name="shape",
)
)
add(
slider(
"size",
"Größe",
_format_size,
minimum=limits.min_size,
maximum=limits.max_size,
step=limits.size_step,
)
)
add(
slider(
"aspect_ratio",
"Seitenverhältnis",
_format_ratio,
minimum=limits.min_aspect_ratio,
maximum=limits.max_aspect_ratio,
step=limits.aspect_ratio_step,
is_enabled=lambda: tools.shape is ShapeKind.BOX,
),
ui.section_gap,
)
add(Label(row(ui.header_height), "MATERIAL", LabelKind.HEADER))
add(
slider(
"restitution",
"Elastizität",
_format_ratio,
minimum=0.0,
maximum=limits.max_restitution,
step=limits.restitution_step,
)
)
add(
slider(
"density",
"Dichte",
_format_density,
minimum=limits.min_density,
maximum=limits.max_density,
step=limits.density_step,
)
)
add(Label(row(ui.header_height), "FARBE", LabelKind.HEADER))
add(
ColorPicker(
row(ui.swatch_size),
palette,
partial(getattr, tools, "color_index"),
partial(setattr, tools, "color_index"),
swatch_size=ui.swatch_size,
swatch_gap=ui.swatch_gap,
name="color",
),
ui.section_gap,
)
add(
SpawnTile(
row(ui.spawn_tile_height),
lambda: tools,
controls.preview_color,
controls.begin_spawn_drag,
hint="Ziehen oder in die Welt klicken",
name="spawn_tile",
),
ui.section_gap,
)
add(Label(row(ui.header_height), "SIMULATION", LabelKind.HEADER))
half = (width - ui.row_gap) // 2
self.widgets.append(
Button(
pygame.Rect(left, y, half, ui.button_height),
lambda: "Weiter" if controls.is_paused() else "Pause",
controls.toggle_pause,
is_active=controls.is_paused,
name="pause",
)
)
add(
Button(
pygame.Rect(
left + half + ui.row_gap, y, width - half - ui.row_gap, ui.button_height
),
lambda: "Schwerkraft an" if controls.gravity_enabled() else "Schwerkraft aus",
controls.toggle_gravity,
is_active=controls.gravity_enabled,
name="gravity",
)
)
add(
Button(
row(ui.button_height),
"Alles löschen",
controls.clear_world,
danger=True,
name="clear",
),
ui.section_gap,
)
self._status_top = y
# ------------------------------------------------------------------ Laufzeit
def widget(self, name: str) -> Widget:
"""Findet ein Widget über seinen Namen."""
for widget in self.widgets:
if widget.name == name:
return widget
raise KeyError(name)
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Leitet ein Event an die Widgets weiter.
Returns:
True, wenn ein Widget das Event verbraucht hat oder ein Klick auf die Sidebar
fiel – solche Klicks dürfen nie in der Welt landen.
"""
if event.type == pygame.MOUSEMOTION:
# Alle Widgets sehen jede Mausbewegung (Hover-Zustand, gezogene Regler).
results = [widget.handle_event(event, mouse_pos) for widget in self.widgets]
return any(results)
for widget in self.widgets:
if widget.handle_event(event, mouse_pos):
return True
is_click = event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP)
return is_click and self.rect.collidepoint(mouse_pos)
def draw(self, surface: pygame.Surface) -> None:
"""Zeichnet Hintergrund, Widgets und Statuszeilen."""
style = self.style
pygame.draw.rect(surface, style.theme.sidebar_background, self.rect)
for widget in self.widgets:
widget.draw(surface, style)
y = self._status_top
for line in self.controls.status_lines():
rendered = style.render_text(line, style.small_font, style.theme.text_dim)
surface.blit(rendered, (self.rect.x + style.ui.padding, y))
y += rendered.get_height()
+59
View File
@@ -0,0 +1,59 @@
"""Gemeinsamer Zeichenstil: Farben, Schriften und ein kleiner Text-Cache."""
from __future__ import annotations
import pygame
from sandbox.config import Color, Theme, UiConfig
def _load_font(names: tuple[str, ...], size: int, *, bold: bool = False) -> pygame.font.Font:
"""Lädt die erste verfügbare Systemschrift, sonst die eingebaute pygame-Schrift."""
for name in names:
path = pygame.font.match_font(name)
if path:
font = pygame.font.Font(path, size)
break
else:
font = pygame.font.Font(None, size)
font.bold = bold
return font
class UiStyle:
"""Farben, Maße und Schriften, die alle Widgets und der Renderer gemeinsam nutzen.
Args:
theme: Farbschema.
ui: Schriften und Maße.
"""
def __init__(self, theme: Theme, ui: UiConfig) -> None:
if not pygame.font.get_init():
pygame.font.init()
self.theme = theme
self.ui = ui
self.title_font = _load_font(ui.font_names, ui.title_font_size, bold=True)
self.label_font = _load_font(ui.font_names, ui.label_font_size)
self.small_font = _load_font(ui.font_names, ui.small_font_size)
self._text_cache: dict[tuple[int, str, Color], pygame.Surface] = {}
def render_text(self, text: str, font: pygame.font.Font, color: Color) -> pygame.Surface:
"""Rendert Text (mit Zwischenspeicher, da sich die meisten Texte selten ändern)."""
key = (id(font), text, color)
surface = self._text_cache.get(key)
if surface is None:
if len(self._text_cache) >= self.ui.text_cache_size:
self._text_cache.clear() # einfach und ausreichend: selten, dann komplett
surface = font.render(text, True, color)
self._text_cache[key] = surface
return surface
def shade(color: Color, factor: float) -> Color:
"""Verdunkelt (factor < 1) oder erhellt (factor > 1) eine Farbe."""
return (
max(0, min(255, round(color[0] * factor))),
max(0, min(255, round(color[1] * factor))),
max(0, min(255, round(color[2] * factor))),
)
+500
View File
@@ -0,0 +1,500 @@
"""Selbst gezeichnete Bedienelemente: Beschriftung, Button, Umschalter, Regler, Farbwahl.
Jedes Widget hat ein Rechteck, verarbeitet pygame-Events (:meth:`Widget.handle_event`
liefert True, wenn es das Event verbraucht hat) und zeichnet sich selbst. Zustände wie
"ist die Pause aktiv?" fragen die Widgets über Callbacks ab, statt sie zu kopieren – so
zeigen sie immer den aktuellen Stand, auch wenn er per Tastenkürzel geändert wurde.
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from enum import Enum
from typing import Generic, TypeAlias, TypeVar
import pygame
from sandbox.config import Color
from sandbox.interaction import ShapeKind, ToolSettings
from sandbox.ui.drawing import draw_box, draw_circle
from sandbox.ui.style import UiStyle, shade
T = TypeVar("T")
#: Fester Text oder Funktion, die den aktuellen Text liefert.
TextSource: TypeAlias = str | Callable[[], str]
def _resolve(text: TextSource) -> str:
return text() if callable(text) else text
def _is_left_press(event: pygame.event.Event, rect: pygame.Rect) -> bool:
return (
event.type == pygame.MOUSEBUTTONDOWN
and event.button == pygame.BUTTON_LEFT
and rect.collidepoint(event.pos)
)
def _is_left_release(event: pygame.event.Event) -> bool:
return event.type == pygame.MOUSEBUTTONUP and event.button == pygame.BUTTON_LEFT
class Widget:
"""Basisklasse aller Bedienelemente.
Args:
rect: Position und Größe auf dem Bildschirm.
name: Eindeutiger Name, über den z. B. Tests das Widget finden.
"""
def __init__(self, rect: pygame.Rect, name: str = "") -> None:
self.rect = pygame.Rect(rect)
self.name = name
self.hovered = False
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Verarbeitet ein Event; True, wenn das Widget es verbraucht hat."""
if event.type == pygame.MOUSEMOTION:
self.hovered = self.rect.collidepoint(mouse_pos)
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet das Widget."""
raise NotImplementedError
class LabelKind(Enum):
"""Darstellungsart einer Beschriftung."""
TITLE = "title"
HEADER = "header"
TEXT = "text"
HINT = "hint"
class Label(Widget):
"""Einzeilige Beschriftung (Text fest oder dynamisch über eine Funktion)."""
def __init__(
self, rect: pygame.Rect, text: TextSource, kind: LabelKind = LabelKind.TEXT, name: str = ""
) -> None:
super().__init__(rect, name)
self.text = text
self.kind = kind
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet den Text linksbündig und vertikal zentriert."""
theme = style.theme
font, color = {
LabelKind.TITLE: (style.title_font, theme.text),
LabelKind.HEADER: (style.small_font, theme.text_dim),
LabelKind.TEXT: (style.label_font, theme.text),
LabelKind.HINT: (style.small_font, theme.text_dim),
}[self.kind]
rendered = style.render_text(_resolve(self.text), font, color)
surface.blit(rendered, (self.rect.x, self.rect.centery - rendered.get_height() // 2))
class Button(Widget):
"""Schaltfläche. Löst beim Loslassen über dem Button aus (übliches UI-Verhalten).
Args:
rect: Position und Größe.
text: Beschriftung (fest oder dynamisch).
on_click: Wird beim Klick aufgerufen.
is_active: Optional; hebt den Button hervor, solange die Funktion True liefert.
danger: Rote Darstellung für zerstörerische Aktionen.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
text: TextSource,
on_click: Callable[[], None],
*,
is_active: Callable[[], bool] | None = None,
danger: bool = False,
name: str = "",
) -> None:
super().__init__(rect, name)
self.text = text
self.on_click = on_click
self.is_active = is_active
self.danger = danger
self.pressed = False
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Drücken merkt sich den Button, Loslassen über dem Button löst aus."""
super().handle_event(event, mouse_pos)
if _is_left_press(event, self.rect):
self.pressed = True
return True
if _is_left_release(event) and self.pressed:
self.pressed = False
if self.rect.collidepoint(event.pos):
self.on_click()
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet Hintergrund (je nach Zustand) und zentrierten Text."""
theme = style.theme
active = self.is_active is not None and self.is_active()
if active:
background = theme.panel_active
elif self.hovered:
background = theme.panel_hover
else:
background = theme.panel
radius = style.ui.corner_radius
pygame.draw.rect(surface, background, self.rect, border_radius=radius)
text_color = theme.danger if self.danger else theme.text
if self.danger or active:
border = theme.danger if self.danger else theme.accent
pygame.draw.rect(
surface, border, self.rect, style.ui.border_width, border_radius=radius
)
rendered = style.render_text(_resolve(self.text), style.label_font, text_color)
surface.blit(rendered, rendered.get_rect(center=self.rect.center))
class SegmentedControl(Widget, Generic[T]):
"""Reihe gleich breiter Optionen, von denen genau eine gewählt ist.
Args:
rect: Position und Größe.
options: Paare aus Wert und Beschriftung.
get_value: Liefert den aktuell gewählten Wert.
set_value: Setzt einen neuen Wert.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
options: Sequence[tuple[T, str]],
get_value: Callable[[], T],
set_value: Callable[[T], None],
name: str = "",
) -> None:
super().__init__(rect, name)
self.options = list(options)
self.get_value = get_value
self.set_value = set_value
def segment_rects(self) -> list[pygame.Rect]:
"""Rechtecke der einzelnen Optionen."""
count = len(self.options)
rects = []
for index in range(count):
left = self.rect.x + self.rect.width * index // count
right = self.rect.x + self.rect.width * (index + 1) // count
rects.append(pygame.Rect(left, self.rect.y, right - left, self.rect.height))
return rects
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Ein Klick auf eine Option wählt sie sofort aus."""
super().handle_event(event, mouse_pos)
for (value, _), rect in zip(self.options, self.segment_rects(), strict=True):
if _is_left_press(event, rect):
self.set_value(value)
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet alle Optionen, die gewählte hervorgehoben."""
theme = style.theme
radius = style.ui.corner_radius
pygame.draw.rect(surface, theme.panel, self.rect, border_radius=radius)
current = self.get_value()
for (value, text), rect in zip(self.options, self.segment_rects(), strict=True):
selected = value == current
if selected:
pygame.draw.rect(surface, theme.panel_active, rect, border_radius=radius)
pygame.draw.rect(
surface, theme.accent, rect, style.ui.border_width, border_radius=radius
)
color = theme.text if selected else theme.text_dim
rendered = style.render_text(text, style.label_font, color)
surface.blit(rendered, rendered.get_rect(center=rect.center))
class Slider(Widget):
"""Schieberegler mit Beschriftung und Wertanzeige.
Oben stehen Beschriftung und aktueller Wert, darunter die Schiene mit Knopf. Der Wert
lässt sich klicken, ziehen oder mit dem Mausrad (in ``step``-Schritten) ändern.
Args:
rect: Position und Größe.
label: Beschriftung.
minimum: Kleinster Wert.
maximum: Größter Wert.
step: Rasterung der Werte.
get_value: Liefert den aktuellen Wert.
set_value: Setzt einen neuen Wert.
value_format: Formatiert den Wert für die Anzeige.
knob_radius: Radius des Knopfs in px (bestimmt auch den Rand der Schiene).
is_enabled: Optional; ein deaktivierter Regler ist ausgegraut und reagiert nicht.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
label: str,
*,
minimum: float,
maximum: float,
step: float,
get_value: Callable[[], float],
set_value: Callable[[float], None],
value_format: Callable[[float], str],
knob_radius: int,
is_enabled: Callable[[], bool] | None = None,
name: str = "",
) -> None:
super().__init__(rect, name)
if not (maximum > minimum and step > 0.0):
raise ValueError("Der Regler braucht maximum > minimum und step > 0")
self.label = label
self.minimum = minimum
self.maximum = maximum
self.step = step
self.get_value = get_value
self.set_value = set_value
self.value_format = value_format
self.knob_radius = knob_radius
self.is_enabled = is_enabled
self.dragging = False
@property
def enabled(self) -> bool:
"""Ob der Regler bedienbar ist."""
return self.is_enabled is None or self.is_enabled()
@property
def track_left(self) -> int:
"""x-Koordinate des linken Schienenendes."""
return self.rect.x + self.knob_radius
@property
def track_width(self) -> int:
"""Breite der Schiene in px."""
return self.rect.width - 2 * self.knob_radius
@property
def track_y(self) -> int:
"""y-Koordinate der Schienenmitte."""
return self.rect.bottom - self.knob_radius
def snap(self, value: float) -> float:
"""Rastet einen Wert auf das Raster ein und begrenzt ihn auf [minimum, maximum]."""
steps = round((value - self.minimum) / self.step)
snapped = self.minimum + steps * self.step
return min(max(snapped, self.minimum), self.maximum)
def value_at(self, x: float) -> float:
"""Wert, der zur Bildschirmposition ``x`` auf der Schiene gehört."""
fraction = (x - self.track_left) / self.track_width
fraction = min(max(fraction, 0.0), 1.0)
return self.snap(self.minimum + fraction * (self.maximum - self.minimum))
def x_of(self, value: float) -> float:
"""Bildschirmposition ``x`` eines Werts (Umkehrung von :meth:`value_at`)."""
fraction = (value - self.minimum) / (self.maximum - self.minimum)
return self.track_left + fraction * self.track_width
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Klicken, Ziehen und Mausrad ändern den Wert."""
super().handle_event(event, mouse_pos)
if not self.enabled:
self.dragging = False
return _is_left_press(event, self.rect) # Klick verschlucken, nichts tun
if _is_left_press(event, self.rect):
self.dragging = True
self.set_value(self.value_at(event.pos[0]))
return True
if event.type == pygame.MOUSEMOTION and self.dragging:
self.set_value(self.value_at(mouse_pos[0]))
return True
if _is_left_release(event) and self.dragging:
self.dragging = False
return True
if event.type == pygame.MOUSEWHEEL and self.rect.collidepoint(mouse_pos):
self.set_value(self.snap(self.get_value() + event.y * self.step))
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet Beschriftung, Wert, Schiene und Knopf."""
theme = style.theme
enabled = self.enabled
text_color = theme.text if enabled else theme.text_dim
label = style.render_text(self.label, style.label_font, text_color)
surface.blit(label, (self.rect.x, self.rect.y))
value = style.render_text(
self.value_format(self.get_value()), style.small_font, theme.text_dim
)
# Unterkanten bündig: Der kleinere Wert steht so auf derselben Grundlinie wie der Text.
label_bottom = self.rect.y + label.get_height()
surface.blit(value, value.get_rect(bottomright=(self.rect.right, label_bottom)))
track = pygame.Rect(0, 0, self.track_width, style.ui.slider_track_height)
track.midleft = (self.track_left, self.track_y)
pygame.draw.rect(surface, theme.panel_hover, track, border_radius=track.height)
knob_x = round(self.x_of(self.get_value()))
filled = track.copy()
filled.width = max(0, knob_x - track.x)
fill_color = theme.accent if enabled else theme.text_dim
pygame.draw.rect(surface, fill_color, filled, border_radius=track.height)
knob_color = theme.text if (self.hovered or self.dragging) and enabled else fill_color
pygame.draw.aacircle(surface, knob_color, (knob_x, self.track_y), self.knob_radius)
class ColorPicker(Widget):
"""Farbfelder zur Auswahl; das erste Feld steht für "Zufallsfarbe".
Args:
rect: Position und Größe.
colors: Farbpalette.
get_index: Liefert den gewählten Palettenindex (``None`` = Zufall).
set_index: Setzt den Palettenindex (``None`` = Zufall).
swatch_size: Kantenlänge eines Farbfelds in px.
swatch_gap: Abstand zwischen den Feldern in px.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
colors: Sequence[Color],
get_index: Callable[[], int | None],
set_index: Callable[[int | None], None],
*,
swatch_size: int,
swatch_gap: int,
name: str = "",
) -> None:
super().__init__(rect, name)
self.colors = list(colors)
self.get_index = get_index
self.set_index = set_index
self.swatch_size = swatch_size
self.swatch_gap = swatch_gap
def swatches(self) -> list[tuple[int | None, pygame.Rect]]:
"""Alle Farbfelder mit ihrem Index (zuerst das Zufallsfeld)."""
indices: list[int | None] = [None, *range(len(self.colors))]
pitch = self.swatch_size + self.swatch_gap
return [
(
index,
pygame.Rect(
self.rect.x + i * pitch, self.rect.y, self.swatch_size, self.swatch_size
),
)
for i, index in enumerate(indices)
]
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Ein Klick auf ein Farbfeld wählt es aus."""
super().handle_event(event, mouse_pos)
for index, rect in self.swatches():
if _is_left_press(event, rect):
self.set_index(index)
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet die Felder; das gewählte bekommt einen hellen Ring."""
theme = style.theme
selected = self.get_index()
for index, rect in self.swatches():
if index is None:
# Zufallsfeld: vier Palettenfarben als Viertel.
half = rect.width // 2
quarters = self.colors[:: max(1, len(self.colors) // 4)][:4]
for k, color in enumerate(quarters):
quarter = pygame.Rect(
rect.x + (k % 2) * half, rect.y + (k // 2) * half, half, half
)
pygame.draw.rect(surface, color, quarter)
else:
pygame.draw.rect(surface, self.colors[index], rect)
if index == selected:
gap = style.ui.selection_ring_gap
ring = rect.inflate(2 * gap, 2 * gap)
pygame.draw.rect(surface, theme.highlight, ring, style.ui.selection_ring_width)
class SpawnTile(Widget):
"""Vorschau-Kachel des nächsten Objekts; aus ihr zieht man Objekte in die Welt.
Args:
rect: Position und Größe.
get_settings: Liefert die aktuellen Werkzeug-Einstellungen.
get_color: Liefert die Farbe des nächsten Objekts.
on_drag_start: Wird beim Drücken auf die Kachel aufgerufen.
hint: Hinweistext unter der Vorschau.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
get_settings: Callable[[], ToolSettings],
get_color: Callable[[], Color],
on_drag_start: Callable[[], None],
*,
hint: str,
name: str = "",
) -> None:
super().__init__(rect, name)
self.get_settings = get_settings
self.get_color = get_color
self.on_drag_start = on_drag_start
self.hint = hint
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Drücken auf die Kachel beginnt das Herausziehen eines neuen Objekts."""
super().handle_event(event, mouse_pos)
if _is_left_press(event, self.rect):
self.on_drag_start()
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet Kachel, maßstäblich verkleinerte Vorschau und Hinweis."""
theme = style.theme
ui = style.ui
background = theme.panel_hover if self.hovered else theme.panel
pygame.draw.rect(surface, background, self.rect, border_radius=ui.corner_radius)
hint = style.render_text(self.hint, style.small_font, theme.text_dim)
hint_rect = hint.get_rect(midbottom=(self.rect.centerx, self.rect.bottom - ui.row_gap))
surface.blit(hint, hint_rect)
settings = self.get_settings()
half_width, half_height = settings.half_extents()
area_height = hint_rect.top - self.rect.top
available = min(self.rect.width, area_height) * ui.preview_scale_limit / 2
scale = min(1.0, available / max(half_width, half_height))
center = (self.rect.centerx, self.rect.top + area_height / 2)
color = self.get_color()
outline = shade(color, theme.outline_shade)
if settings.shape is ShapeKind.CIRCLE:
draw_circle(
surface, color, outline, center, half_width * scale, outline_width=ui.outline_width
)
else:
draw_box(
surface,
color,
outline,
center,
(half_width * scale, half_height * scale),
outline_width=ui.outline_width,
)
+44
View File
@@ -0,0 +1,44 @@
"""Abbildung zwischen Bildschirm- und Weltkoordinaten.
Die Welt liegt rechts neben der Sidebar. Weil beide Koordinatensysteme in Pixeln rechnen
und die y-Achse gleich ausrichten, besteht die Abbildung nur aus einer Verschiebung.
"""
from __future__ import annotations
from sandbox.physics import Vector2
class Viewport:
"""Rechteckiger Bildschirmbereich, in dem die Welt gezeichnet wird.
Args:
left: Linker Rand auf dem Bildschirm in px.
top: Oberer Rand auf dem Bildschirm in px.
width: Breite in px (= Breite der Welt).
height: Höhe in px (= Höhe der Welt).
"""
def __init__(self, left: int, top: int, width: int, height: int) -> None:
self.left = left
self.top = top
self.width = width
self.height = height
@property
def rect(self) -> tuple[int, int, int, int]:
"""Bereich als ``(left, top, width, height)``."""
return (self.left, self.top, self.width, self.height)
def contains(self, screen_pos: tuple[float, float]) -> bool:
"""True, wenn ein Bildschirmpunkt in der Welt liegt."""
x, y = screen_pos
return self.left <= x < self.left + self.width and self.top <= y < self.top + self.height
def to_world(self, screen_pos: tuple[float, float]) -> Vector2:
"""Bildschirmpunkt → Weltpunkt."""
return Vector2(screen_pos[0] - self.left, screen_pos[1] - self.top)
def to_screen(self, point: Vector2) -> tuple[float, float]:
"""Weltpunkt → Bildschirmpunkt."""
return (point.x + self.left, point.y + self.top)