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
+159
View File
@@ -0,0 +1,159 @@
# Architektur
## Schichten
Das Projekt besteht aus drei Schichten. Abhängigkeiten zeigen nur nach unten:
```mermaid
flowchart TB
subgraph APP["Anwendung (pygame-ce)"]
main["main.py<br/>App · Game-Loop"]
input["input_handler.py<br/>Maus/Tastatur → Aktionen"]
renderer["renderer.py<br/>Welt zeichnen"]
ui["ui/<br/>Sidebar · Widgets · Stil"]
end
subgraph TOOLS["Interaktion (ohne pygame)"]
drag["interaction/drag.py<br/>DragController"]
tracker["interaction/velocity_tracker.py<br/>VelocityTracker"]
tools["interaction/tools.py<br/>ToolSettings · BodyFactory"]
demo["demo.py<br/>Beispielszene"]
viewport["viewport.py<br/>Bildschirm ↔ Welt"]
end
subgraph PHYS["Physik (nur math)"]
world["world.py<br/>World"]
solver["solver.py<br/>ContactSolver"]
collision["collision.py<br/>Narrowphase"]
broad["broadphase.py<br/>Sweep-and-Prune"]
sleep["sleep.py<br/>Schlafmodus"]
body["body.py · shapes.py<br/>Body · Material · Formen"]
vector["vector.py<br/>Vector2"]
timestep["timestep.py<br/>FixedTimestep"]
end
config["config.py<br/>alle Konstanten"]
APP --> TOOLS
APP --> PHYS
TOOLS --> PHYS
APP --> config
TOOLS --> config
PHYS --> config
```
| Schicht | Pakete/Module | Darf importieren |
|---|---|---|
| **Physik** | `sandbox.physics` | nur `math`, `typing`, `dataclasses`, `sandbox.config` |
| **Interaktion** | `sandbox.interaction`, `sandbox.demo`, `sandbox.viewport` | Physik, Standardbibliothek |
| **Anwendung** | `sandbox.main`, `sandbox.input_handler`, `sandbox.renderer`, `sandbox.ui` | alles, inklusive pygame |
**Warum diese Trennung?** Die Physik soll ohne Grafik laufen und isoliert testbar sein. Das
ist eine Anforderung, und `tests/test_architecture.py` prüft sie doppelt: statisch über den
Syntaxbaum und zur Laufzeit in einem frischen Interpreter, in dem nach
`import sandbox.physics` kein pygame geladen sein darf. Die Interaktionslogik – etwa die
Glättung der Wurfgeschwindigkeit – arbeitet mit Weltkoordinaten und Zeitstempeln statt mit
pygame-Events. So lässt auch sie sich ohne Fenster testen.
`config.py` enthält ausschließlich Konstanten in unveränderlichen Dataclasses. Im Code gibt
es keine "Magic Numbers". Tests variieren einzelne Werte mit `dataclasses.replace`.
## Module
| Modul | Verantwortung |
|---|---|
| `physics/vector.py` | `Vector2`: unveränderlicher 2D-Vektor (Addition, Skalierung, Skalar- und Kreuzprodukt, Länge, Normalisierung …) |
| `physics/shapes.py` | `Circle`, `Box` (AABB), `HalfPlane` (Wand): Fläche, Trägheitsmoment, Punkttest |
| `physics/body.py` | `Material`, `Body`: Zustand, Masse = Fläche × Dichte, inverse Massen, Schlafzustand |
| `physics/collision.py` | `Contact` und die Tests Kreis–Kreis, Kreis–Rechteck, Rechteck–Rechteck, Körper–Wand |
| `physics/broadphase.py` | Sweep-and-Prune: Kandidatenpaare in O(n·log n + k) |
| `physics/solver.py` | `ContactSolver`: Sequential Impulses (Normale, Reibung, Rollwiderstand), Warm-Starting, Positionskorrektur |
| `physics/sleep.py` | Kontakt-Inseln (Union-Find), Einschlafen und Aufwecken |
| `physics/timestep.py` | `FixedTimestep`: Frame-Zeit → Anzahl fester Schritte, Interpolationsgewicht |
| `physics/world.py` | `World`: Körper und Wände verwalten, `step(dt)` orchestrieren |
| `interaction/tools.py` | `ToolSettings` (Sidebar-Auswahl), `BodyFactory` (Körper erzeugen), `BodyStyle` (Farbe) |
| `interaction/drag.py` | `DragController`: Greifen, per Geschwindigkeit ziehen, Werfen |
| `interaction/velocity_tracker.py` | geglättete Mausgeschwindigkeit (Zeitfenster + exponentielle Glättung) |
| `viewport.py` | Verschiebung zwischen Bildschirm- und Weltkoordinaten |
| `input_handler.py` | Events verteilen: Sidebar zuerst, sonst Werkzeug-Aktion in der Welt; Tastenkürzel |
| `ui/widgets.py` | selbst gezeichnete Widgets: `Label`, `Button`, `SegmentedControl`, `Slider`, `ColorPicker`, `SpawnTile` |
| `ui/sidebar.py` | Aufbau und Layout der Werkzeugleiste |
| `ui/style.py`, `ui/drawing.py` | Schriften, Text-Cache, Zeichenhelfer für Kreise und Rechtecke |
| `renderer.py` | Welt zeichnen: Interpolation, Hervorhebung, Wurfpfeil, Vorschau, Debug-Ansicht |
| `main.py` | `App`: alles verdrahten, Hauptschleife, Kommandozeile |
| `demo.py` | Beispielszene für `--demo` |
## Datenfluss im Game-Loop
Die Hauptschleife (`App.run`) ruft pro Bild `App.frame(frame_time)` auf:
```mermaid
sequenceDiagram
autonumber
participant Loop as App.run
participant App as App.frame
participant Input as InputHandler
participant Sidebar
participant Drag as DragController
participant Step as FixedTimestep
participant World
participant Draw as Renderer + Sidebar
Loop->>App: frame_time (Clock.tick, max. 60 FPS)
loop alle pygame-Events
App->>Input: handle_event(event, zeit)
Input->>Sidebar: Event anbieten
alt Sidebar verbraucht es
Sidebar-->>Input: True (Regler, Button, Kachel …)
else Klick/Bewegung in der Welt
Input->>Drag: grab / move / release
Input->>World: Körper erzeugen / löschen
end
end
App->>Input: update(zeit) – Mausposition an Tracker, Hover bestimmen
App->>Step: advance(frame_time)
Step-->>App: n feste Schritte (meist 2 × 1/120 s)
loop n-mal
App->>Drag: apply(dt) – Geschwindigkeit Richtung Maus
App->>World: step(dt)
end
App->>Draw: Welt (interpoliert mit alpha), Sidebar, Vorschau
App->>Loop: display.flip()
```
Wichtige Punkte:
- **Entkopplung:** Die Bildrate (bis 60 FPS) und die Physik (fest 120 Hz) sind unabhängig.
Der Akkumulator in `FixedTimestep` übersetzt zwischen beiden. Das Interpolationsgewicht
`alpha` glättet die Darstellung.
- **Ziehen im Physiktakt:** `DragController.apply` läuft vor *jedem* Physikschritt, nicht
nur einmal pro Bild. Der gezogene Körper wird wie jeder andere vom Solver behandelt.
- **Mausgeschwindigkeit pro Bild:** `InputHandler.update` meldet die Mausposition in jedem
Frame an den Tracker, auch ohne Maus-Event. Nur so merkt er, dass die Maus stillsteht.
- **Pause:** Keine Physikschritte. Ein gezogener Körper wird direkt versetzt
(`DragController.place`).
## Ablauf eines Physikschritts
Details und Formeln in [PHYSICS.md](PHYSICS.md#4-ablauf-eines-simulationsschritts):
```mermaid
flowchart LR
Z[Zustand merken] --> A[v += g·dt] --> B[Broadphase] --> C[Narrowphase]
C --> D[Schlaf-Inseln<br/>wecken] --> E[Wandkontakte] --> F[Solver:<br/>Warm-Start +<br/>8 Iterationen]
F --> G[v begrenzen] --> H[x += v·dt] --> I[Positions-<br/>korrektur]
I --> K[Notbremse] --> J[Schlafmodus]
```
## Entwurfsentscheidungen
| Entscheidung | Begründung |
|---|---|
| `Vector2` ist unveränderlich (Zuweisung löst `AttributeError` aus) | Keine Aliasing-Fehler: Ein Körper kann seine Position herausgeben, ohne dass jemand sie über die geteilte Referenz verändert. |
| Solver-Schleifen rechnen mit Floats statt mit `Vector2` | Sie laufen tausendfach pro Schritt. Objekt-Erzeugung ist in Python teuer; die Formeln sind komponentenweise identisch. |
| Kontakte kanonisch geordnet (Kreis vor Rechteck vor Wand, sonst nach `id`) | Ein Körperpaar hat in jedem Schritt dieselbe Orientierung – Voraussetzung für das Warm-Starting. |
| Wände sind statische `Body`-Objekte mit `HalfPlane`-Form | Der Solver behandelt Wandkontakte ohne Sonderfall (inverse Masse 0). Halbebenen sind tunnelsicher. |
| Rechtecke drehen nie (Trägheitsmoment unendlich) | Die Aufgabe verlangt achsenparallele Rechtecke; jeder Kontakt braucht dann nur einen Punkt. |
| Ziehen über Geschwindigkeit statt Teleport | Das gezogene Objekt kollidiert weiter und kann nicht durch Wände geschoben werden. |
| Positionskorrektur per Projektion statt Baumgarte-Geschwindigkeit | Überlappende Objekte werden getrennt, ohne davongeschleudert zu werden. |
| Schlafmodus über Kontakt-Inseln, die gemeinsam einschlafen und gemeinsam aufwachen | Verhindert das gegenseitige Aufwecken einzelner Körper und schlafende Körper, die nach dem Wegziehen ihrer Stütze in der Luft hängen. |
| Abprallen aus der Geschwindigkeit vom Schrittbeginn | Sonst ginge die im selben Schritt addierte Schwerkraft in den Rückprall ein – ein elastischer Ball gewönne Höhe. |
| Widgets fragen Zustände über Callbacks ab | Die Anzeige stimmt immer, auch wenn ein Wert per Tastenkürzel geändert wurde. |
| Keine GUI-Bibliothek | pygame-ce genügt für die wenigen Widgets; weniger Abhängigkeiten. |
+55
View File
@@ -0,0 +1,55 @@
# Bedienung
## Maus in der Welt
| Eingabe | Aktion |
|---|---|
| **Linksklick ins Leere** | Erzeugt ein Objekt mit den Einstellungen der Sidebar an der Mausposition. Hält man die Taste gedrückt, hängt das neue Objekt sofort an der Maus und kann direkt geworfen werden. |
| **Linksklick auf ein Objekt, gedrückt halten** | Greift das Objekt (gelbe Umrandung). Es folgt der Maus, kollidiert dabei aber weiter: Leichtere Objekte werden weggeschoben, Wände und schwere Stapel bremsen es. |
| **Loslassen während der Bewegung** | Wirft das Objekt mit der (geglätteten) Mausgeschwindigkeit der letzten Frames. Der gelbe Pfeil zeigt beim Ziehen Richtung und Stärke des Wurfs. |
| **Loslassen nach kurzem Stillhalten** | Das Objekt fällt einfach herunter, ohne Schwung. |
| **Rechtsklick auf ein Objekt** | Löscht das Objekt. |
| **Mausrad** | Ändert die Größe neuer Objekte. |
| **Maus über einem Objekt** | Weiße Umrandung und Hand-Cursor: Das Objekt kann gegriffen werden. |
| **Maus über leerer Fläche** | Halbtransparente Vorschau des Objekts, das ein Klick erzeugen würde. |
## Sidebar
| Element | Wirkung |
|---|---|
| **Kreis / Rechteck** | Form neuer Objekte. |
| **Größe** | Radius bzw. halbe Kantenlänge (12–80 px). |
| **Seitenverhältnis** | Breite : Höhe von Rechtecken (0,5–2). Die Fläche und damit die Masse bleibt dabei gleich – außer bei sehr kleinen Rechtecken, deren kurze Seite nicht unter die Mindestgröße (24 px) fallen darf. Bei Kreisen ausgegraut. |
| **Elastizität** | Stoßzahl 0 (kein Abprallen) bis 1 (vollkommen elastisch). |
| **Dichte** | Masse pro Fläche (0,2–5). Schwere Objekte schieben leichte leichter weg. |
| **Farbe** | Feste Farbe oder (erstes Feld) Zufallsfarbe. |
| **Vorschau-Kachel** | Zeigt das nächste Objekt in seiner echten Farbe. Mit gedrückter Maustaste in die Welt ziehen: Das Objekt erscheint am Weltrand und hängt an der Maus. Wird über der Sidebar losgelassen, entsteht kein Objekt. |
| **Pause / Weiter** | Hält die Simulation an. Im Pausenmodus lassen sich Objekte erzeugen und exakt platzieren; beim Loslassen werden sie nicht geworfen. |
| **Schwerkraft an / aus** | Schaltet die Schwerkraft um und weckt alle ruhenden Objekte. |
| **Alles löschen** | Entfernt alle Objekte. |
Alle Regler reagieren auf Klicken, Ziehen und das Mausrad (in Rasterschritten). Beim Ziehen
darf die Maus die Sidebar verlassen.
## Tastatur
| Taste | Aktion |
|---|---|
| `Leertaste` | Pause / Weiter |
| `G` | Schwerkraft an / aus |
| `C` | Alles löschen |
| `Entf` | Objekt unter der Maus löschen |
| `1` | Form: Kreis |
| `2` | Form: Rechteck |
| `F1` | Debug-Ansicht: Kontaktpunkte (gelb), Kontaktnormalen (rot), schlafende Objekte (graue Umrandung), Kennzahlen |
| `Esc` | Beenden |
Die Tastenbelegung steht in `KEY_BINDINGS` in [`src/sandbox/config.py`](../src/sandbox/config.py).
## Kommandozeile
| Option | Wirkung |
|---|---|
| `--demo` | startet mit einer Beispielszene |
| `--screenshot DATEI` | läuft `--seconds` Sekunden, speichert ein Bildschirmfoto und beendet sich |
| `--seconds S` | Laufzeit vor dem Bildschirmfoto (Standard: 3) |
+475
View File
@@ -0,0 +1,475 @@
# Physik der Sandbox
Dieses Dokument erklärt, *was* die Physik-Engine berechnet und *warum* sie es so tut. Alle
Formeln finden sich im Code wieder, meist mit derselben Bezeichnung. Die Zahlenwerte stehen
in [`src/sandbox/config.py`](../src/sandbox/config.py) (`PhysicsConfig`).
Die Physik-Schicht (`src/sandbox/physics/`) verwendet ausschließlich das Standardmodul
`math` sowie `typing`/`dataclasses` für Typangaben. Ein Architektur-Test stellt sicher,
dass dort nie pygame importiert wird.
## Inhalt
1. [Einheiten und Koordinaten](#1-einheiten-und-koordinaten)
2. [Körper: Masse und Trägheit](#2-körper-masse-und-trägheit)
3. [Integration mit festem Zeitschritt](#3-integration-mit-festem-zeitschritt)
4. [Ablauf eines Simulationsschritts](#4-ablauf-eines-simulationsschritts)
5. [Kollisionserkennung](#5-kollisionserkennung)
6. [Impulsbasierte Kollisionsauflösung](#6-impulsbasierte-kollisionsauflösung)
7. [Restitution (Elastizität)](#7-restitution-elastizität)
8. [Reibung und Rollen](#8-reibung-und-rollen)
9. [Rollwiderstand](#9-rollwiderstand)
10. [Positionskorrektur](#10-positionskorrektur)
11. [Ruhe ohne Zittern: Schlafmodus](#11-ruhe-ohne-zittern-schlafmodus)
12. [Tunneln verhindern](#12-tunneln-verhindern)
13. [Ziehen und Werfen](#13-ziehen-und-werfen)
14. [Parameterübersicht](#14-parameterübersicht)
---
## 1. Einheiten und Koordinaten
| Größe | Einheit |
|---|---|
| Länge | Pixel (px) |
| Zeit | Sekunde (s) |
| Geschwindigkeit | px/s |
| Masse | Fläche × Dichte (px² · Dichte; nur Verhältnisse zählen) |
Der Ursprung liegt oben links in der Welt, **x zeigt nach rechts, y nach unten** – wie auf
dem Bildschirm. Die Schwerkraft ist deshalb `g = (0, +980) px/s²`; bei einem Maßstab von
100 px pro Meter entspricht das 9,8 m/s².
Weil y nach unten zeigt, bedeutet ein positiver Drehwinkel eine Drehung **im
Uhrzeigersinn** auf dem Bildschirm. Die Formeln sind davon unabhängig, solange man
konsequent `a × b = a.x·b.y − a.y·b.x` verwendet.
## 2. Körper: Masse und Trägheit
Jeder Körper (`Body`) hat eine Form, eine Position (Schwerpunkt), eine Geschwindigkeit und
bei Kreisen zusätzlich Winkel und Winkelgeschwindigkeit.
**Masse = Fläche × Dichte.** Damit sind größere Körper bei gleicher Dichte schwerer:
| Form | Fläche | Trägheitsmoment |
|---|---|---|
| Kreis (Radius r) | `π·r²` | `I = ½·m·r²` (homogene Scheibe) |
| Rechteck (halbe Kanten hw, hh) | `4·hw·hh` | unendlich (dreht nie) |
Herleitung des Scheiben-Trägheitsmoments: Die Scheibe zerfällt in dünne Ringe mit Radius s
und Masse `dm = m/(π·r²) · 2π·s·ds`. Jeder Ring trägt `s²·dm` bei:
```text
I = (2m / r²) · ∫₀ʳ s³ ds = (2m / r²) · r⁴/4 = ½·m·r²
```
Rechtecke sind laut Aufgabenstellung **achsenparallel**. Ein unendliches Trägheitsmoment
(also `1/I = 0`) garantiert, dass kein Drehimpuls sie jemals drehen kann.
**Inverse Massen.** Gespeichert werden zusätzlich `1/m` und `1/I`. Wände haben `1/m = 0`
(unendliche Masse). So passen bewegliche und unbewegliche Körper ohne Sonderfälle in
dieselben Formeln: Ein Impuls P ändert die Geschwindigkeit um `P/m` – bei einer Wand um 0.
## 3. Integration mit festem Zeitschritt
### Semi-impliziter Euler
Pro Schritt `dt = 1/120 s`:
```text
v ← v + g·dt (zuerst die Geschwindigkeit …)
x ← x + v·dt (… dann die Position mit der NEUEN Geschwindigkeit)
θ ← θ + ω·dt
```
Der explizite Euler (Position mit der *alten* Geschwindigkeit) gewinnt bei Schwingungen und
Umlaufbahnen systematisch Energie und wird instabil. Der semi-implizite (symplektische)
Euler kostet genauso wenig, bleibt aber energetisch stabil.
Für den freien Fall ergibt sich nach n Schritten exakt `v = n·g·dt` und
`s = g·dt²·n(n+1)/2`. Das weicht nur um den Faktor `(1 + 1/n)` vom analytischen
`s = ½·g·t²` ab; nach 0,5 s sind das 1,7 %. Genau diese Beziehung prüft ein Test.
### Fester Zeitschritt, entkoppelt von der Bildrate
Die Stabilität hängt von `dt` ab. Mit der schwankenden Frame-Dauer als `dt` würde sich die
Simulation auf jedem Rechner anders verhalten. Ein Ruckler von 200 ms ließe Körper außerdem
durch andere hindurchspringen. Deshalb sammelt ein Akkumulator (`FixedTimestep`) die real
vergangene Zeit und gibt sie in festen Portionen weiter:
```text
akkumulator += frame_zeit
while akkumulator ≥ dt: world.step(dt); akkumulator −= dt
alpha = akkumulator / dt (Rest für die Interpolation)
```
- **Obergrenze** `max_steps_per_frame`: Schafft der Rechner die Physik nicht in Echtzeit,
wird der Rückstand verworfen, statt ihn endlos mitzuschleppen ("Spiral of Death").
- **Interpolation:** Gezeichnet wird `lerp(vorige_position, position, alpha)`. Die Physik
läuft mit 120 Hz, der Bildschirm z. B. mit 60 Hz – ohne Interpolation ruckelt die
Bewegung sichtbar.
## 4. Ablauf eines Simulationsschritts
`World.step(dt)` führt nacheinander aus:
```mermaid
flowchart TD
A[Zustand merken<br/>für Interpolation] --> B[Geschwindigkeiten integrieren<br/>v += g·dt]
B --> C[Broadphase<br/>Sweep-and-Prune]
C --> D[Narrowphase<br/>Kontakte Körper–Körper]
D --> E[Berührte Schläfer wecken]
E --> F[Wandkontakte]
F --> G[Impulse lösen<br/>Normale · Reibung · Rollwiderstand]
G --> H[Geschwindigkeit begrenzen]
H --> I[Positionen integrieren<br/>x += v·dt]
I --> J[Positionskorrektur]
J --> K[Notbremse an den Wänden]
K --> L[Schlafmodus aktualisieren]
```
Vor jedem Schritt setzt die Anwendung die Geschwindigkeit eines gezogenen Körpers
(siehe [Abschnitt 13](#13-ziehen-und-werfen)).
## 5. Kollisionserkennung
Jede Prüfung liefert einen **Kontakt** mit
- **Normale n** – Einheitsvektor von Körper A zu Körper B,
- **Eindringtiefe** – Überlappung entlang n (≥ 0),
- **Kontaktpunkt** – Angriffspunkt der Kräfte.
### Broadphase: Sweep-and-Prune
Alle Paare genau zu prüfen kostet n·(n−1)/2 Tests – bei 150 Körpern über 11 000 pro
Schritt. Deshalb werden die umschließenden Boxen nach ihrer linken Kante sortiert. Für jede
Box müssen nur die Nachfolger geprüft werden, die links von ihrer rechten Kante beginnen.
Der Aufwand sinkt auf etwa O(n·log n + k) für k benachbarte Paare. Paare aus zwei
schlafenden Körpern werden übersprungen.
### Kreis–Kreis
```text
d = |p_B − p_A|, Kontakt, wenn d ≤ r_A + r_B
n = (p_B − p_A) / d, Tiefe = r_A + r_B − d
```
Der Kontaktpunkt liegt in der Mitte des Überlappungsbereichs. Liegen die Mittelpunkte exakt
aufeinander, ist die Richtung unbestimmt. Dann wird die feste Ersatznormale (0, −1)
verwendet, statt durch 0 zu teilen.
### Kreis–Rechteck
Den Kreismittelpunkt (relativ zum Rechteck) komponentenweise auf das Rechteck klemmen. Das
liefert den nächstgelegenen Punkt q des Rechtecks:
```text
q = (clamp(dx, −hw, hw), clamp(dy, −hh, hh))
Kontakt, wenn |Mittelpunkt − q| ≤ r
```
Sonderfall: Liegt der Kreismittelpunkt *im* Rechteck, ist q der Mittelpunkt selbst und
liefert keine Richtung. Dann wird der Kreis über die nächstgelegene Kante hinausgeschoben;
die Tiefe ist Radius + Abstand zu dieser Kante.
### Rechteck–Rechteck
Zwei achsenparallele Rechtecke überlappen genau dann, wenn sich ihre Projektionen auf
**beide** Achsen überlappen (Trennachsensatz):
```text
overlap_x = hw_A + hw_B − |dx|, overlap_y = hh_A + hh_B − |dy|
```
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.
### Wände als Halbebenen
Boden, Decke und Seiten sind Halbebenen `n·p ≥ offset` (die Normale zeigt in die Welt).
Die Reichweite eines Rechtecks entlang n ist `|n.x|·hw + |n.y|·hh`, die eines Kreises r.
Weil eine Halbebene unendlich dick ist, wird ein Körper **auch dann erkannt, wenn er in
einem Schritt komplett hinter die Wandlinie springt** – er kann nicht hindurchtunneln.
## 6. Impulsbasierte Kollisionsauflösung
### Herleitung des Kollisionsimpulses
Relativgeschwindigkeit des Kontaktpunkts (B relativ zu A), mit den Hebelarmen r vom
Schwerpunkt zum Kontaktpunkt und `ω × r = ω·(−r.y, r.x)`:
```text
v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), v_n = v_rel · n
```
`v_n < 0` bedeutet: Die Körper nähern sich an. Ein Impuls `P = λ·n` wirkt auf B positiv und
auf A negativ (actio = reactio):
```text
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 λ:
```text
v_n' = v_n + λ·K, K = 1/m_A + 1/m_B + (r_A × n)²/I_A + (r_B × n)²/I_B
```
Aus Newtons Stoßgesetz `v_n' = −e·v_n` folgt:
```text
λ = −(1 + e) · v_n / K
```
`1/K` ist die **effektive Masse** des Kontakts – die träge Masse, die der Stoß entlang n
"spürt". Für einen einzelnen zentralen Stoß erhält diese Formel den Gesamtimpuls exakt und
liefert die Lehrbuchergebnisse. Beim elastischen Stoß zweier Kreise mit Massenverhältnis
1 : 3 und Geschwindigkeiten 200 bzw. −100 px/s kommen zum Beispiel exakt −250 und 50 px/s
heraus. Ein Test prüft das.
**Besonderheit dieser Engine:** Bei Kreisen geht die Normale durch den Mittelpunkt
(r ∥ n, also r × n = 0), und Rechtecke drehen sich nie (1/I = 0). Die Drehterme von K
verschwinden daher für die Normale – **Normalimpulse erzeugen kein Drehmoment**. Nur die
Reibung bringt Kreise ins Rollen.
### Viele Kontakte: Sequential Impulses
Liegt ein Körper auf mehreren anderen, beeinflussen sich die Kontakte gegenseitig. Statt
ein Gleichungssystem zu lösen, werden die Kontakte reihum mehrfach aufgelöst
(Gauß-Seidel, `velocity_iterations = 8`). Zwei Details machen das Verfahren korrekt und
stabil:
1. **Akkumulierte Impulse klemmen.** Geklemmt wird der über alle Durchläufe *summierte*
Impuls (`P_n ≥ 0`), nicht der Teilimpuls eines Durchlaufs. Ein späterer Durchlauf darf
einen zu großen früheren Impuls also wieder zurücknehmen. Das Verfahren konvergiert so
gegen die physikalische Lösung, in der Kontakte nur drücken und nie ziehen.
2. **Warm-Starting.** Die akkumulierten Impulse des vorigen Schritts werden zu Beginn erneut
angewendet. Ruhende Kontakte ändern sich kaum, der Startwert ist also schon fast richtig –
Stapel stehen dadurch nach wenigen Durchläufen stabil. Hat sich die Normale stark
gedreht (Skalarprodukt < 0,95), wird der alte Impuls verworfen.
Reihenfolge je Kontakt und Durchlauf: **Normale → Reibung → Rollwiderstand.** Reibung und
Rollwiderstand sind durch den Normalimpuls begrenzt. Werden sie danach gelöst, gelten ihre
Grenzen nach jedem Durchlauf exakt für den aktuellen Normalimpuls.
## 7. Restitution (Elastizität)
Die Stoßzahl `e ∈ [0, 1]` ist das Verhältnis von Trenn- zu Aufprallgeschwindigkeit:
`e = 0` völlig unelastisch, `e = 1` vollkommen elastisch.
- **Energieverlust:** Beim zentralen Stoß geht `ΔE = ½·μ·(1 − e²)·v_rel²` verloren
(μ = m_A·m_B/(m_A + m_B), die reduzierte Masse). Ein Test prüft genau diesen Wert.
- **Sprunghöhe:** Ein fallender Ball erreicht nach dem Aufprall `e²` seiner Fallhöhe
(Energie ∝ v²).
- **Mischregel:** `e = max(e_A, e_B)` – ein Gummiball springt auch von einem Holzklotz
ab. Wände haben `e = 0`, sodass allein das Material des Körpers zählt.
- **Schwellwert (`restitution_threshold` = 50 px/s):** Unterhalb dieser
Aufprallgeschwindigkeit wird nicht abgeprallt. Sonst würden aufliegende Körper durch
winzige Dauer-Hüpfer zittern.
- **Aufprallgeschwindigkeit vom Schrittbeginn:** `v_n` wird aus den Geschwindigkeiten *vor*
Schwerkraft und Warm-Starting bestimmt (`Body.previous_velocity`). Nähme man die
Geschwindigkeit nach der Schwerkraft, prallte ein Ball mit `e·(v + g·dt)` statt `e·v` ab.
Ein vollkommen elastischer Ball (e = 1) gewönne dann mit jedem Aufprall Höhe – Energie
aus dem Nichts. So springt er dagegen immer wieder genau auf seine Fallhöhe; ein Test
prüft das über mehrere Sprünge.
- **Gehaltene Körper prallen nicht ab:** Die Hand fängt den Stoß ab. Andernfalls würde ein
gegen ein Hindernis gedrückter Körper in jedem Schritt zurückprallen, sofort wieder
herangezogen werden und dabei sichtbar zittern.
## 8. Reibung und Rollen
**Coulomb-Reibung:** Entlang der Tangente `t = (−n.y, n.x)` soll die Gleitgeschwindigkeit
verschwinden. Der Reibungsimpuls ist aber begrenzt:
```text
|P_t| ≤ μ · P_n
```
Reicht die Grenze, haften die Körper (Haftreibung). Sonst rutschen sie mit konstanter
Verzögerung `μ·g` (Gleitreibung): Eine Box mit 300 px/s kommt nach `v²/(2μg)` zum Stehen.
Gemischt wird mit dem geometrischen Mittel `μ = √(μ_A·μ_B)` – ein glatter Partner macht
den Kontakt glatt.
**Rollen:** Der Reibungsimpuls greift am Kontaktpunkt an, nicht im Schwerpunkt. Bei Kreisen
erzeugt er daher ein Drehmoment `r × P_t`, die effektive Masse enthält den Term
`(r × t)²/I`. Eine rutschende Scheibe wird gebremst und zugleich in Drehung versetzt, bis
die Rollbedingung `v = ω·r` gilt. Drehimpulserhaltung um den Kontaktpunkt liefert die
Endgeschwindigkeit – unabhängig vom Reibwert:
```text
v_end = v₀ / (1 + I/(m·r²)) = v₀ / 1,5 = ⅔ · v₀
```
Auch das prüft ein Test. Damit man die Drehung sieht, zeichnet der Renderer in jeden Kreis
einen Radius-Strich.
## 9. Rollwiderstand
Reines Rollen verbraucht keine Reibungsenergie – ein rollender Kreis würde ewig rollen. Ein
Rechteck auf einem kleinen Kreis führe darauf wie auf einer Rolle davon. Real verformen sich
Kreis und Unterlage ein wenig, was ein bremsendes Moment erzeugt:
```text
M ≤ μ_r · N · r (μ_r = rolling_resistance = 0,1)
```
Pro Schritt entspricht das einem Drehimpuls `≤ μ_r · P_n · r`. Umgesetzt ist das wie die
Reibung: als begrenzter Impuls, hier aber auf die relative Winkelgeschwindigkeit
`ω_B − ω_A`, mit der effektiven Masse `1/(1/I_A + 1/I_B)`. Weil die Grenze mit der
Normalkraft wächst, kommen schwer belastete "Rollen" schnell zur Ruhe, während ein frei
rollender Ball weit rollt.
Für einen rollenden Ball ergibt sich eine konstante Verzögerung:
```text
a = μ_r·g / (1 + I/(m·r²)) = μ_r·g / 1,5 ≈ 65 px/s²
```
Ein Ball mit 200 px/s rollt so etwa 300 px weit. Der Test misst genau diese Verzögerung.
## 10. Positionskorrektur
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. Ohne Korrektur würden Stapel langsam ineinander einsinken.
Die Korrektur verschiebt die **Positionen** direkt ("Projektion"), im Verhältnis der
inversen Massen:
```text
C = factor · max(Tiefe − slop, 0) (höchstens max_position_correction)
p_A −= n · C · (1/m_A) / (1/m_A + 1/m_B)
p_B += n · C · (1/m_B) / (1/m_A + 1/m_B)
```
- **Warum Positionen statt Zusatzgeschwindigkeit (Baumgarte)?** 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 Projektion erzeugt keine Bewegungsenergie.
- **Slop (0,5 px):** Eine kleine Resttiefe bleibt stehen. So wird der Kontakt im nächsten
Schritt sicher wieder erkannt, statt ständig zwischen "Kontakt" und "kein Kontakt" zu
wechseln – auch das wäre sichtbares Zittern.
- **Aktuelle Tiefe ohne erneute Kollisionsprüfung:** Tiefe bei der Erkennung minus die
seither erfolgte Relativverschiebung entlang n. Das ist exakt für Wände und Rechtecke und
eine sehr gute Näherung für Kreise.
- **Notbremse:** Kein Körper darf mehr als `max_wall_penetration` (12 px) in eine Wand
ragen. Der Wert liegt bewusst über der größten Eindringtiefe, die ein frei fliegender
Körper in einem Schritt erreichen kann (`MAX_SPEED · dt ≈ 10,8 px`). Normale Aufpralle
löst deshalb immer der Solver; die Bremse greift nur in Extremfällen, etwa wenn ein
gezogenes Objekt einen Körper gegen die Wand quetscht. Würde sie schon bei normalen
Aufprallen greifen, verschöbe sie Positionen und fügte dabei Lageenergie hinzu.
## 11. Ruhe ohne Zittern: Schlafmodus
Ein Solver mit endlich vielen Iterationen hinterlässt immer winzige Restbewegungen. Ein
Stapel "atmet" dadurch sichtbar oder kriecht. Deshalb werden ruhende Körper eingefroren:
1. Jeder Körper zählt, wie lange er ununterbrochen fast stillsteht
(`|v| ≤ 4 px/s` und `|ω| ≤ 0,1 rad/s`).
2. Körper, die sich direkt oder über andere berühren, bilden eine **Insel** (Union-Find über
die Kontakte). Wände verbinden keine Inseln, sonst wäre alles am Boden eine einzige Insel.
3. Eine Insel schläft ein, wenn **alle** ihre Körper seit `time_to_sleep` (0,5 s) ruhen.
Jeder Körper merkt sich dabei seine Insel (`Body.sleep_island`).
4. Schlafende Körper werden weder integriert noch aufgelöst – das spart auch Rechenzeit.
5. **Aufwecken – immer die ganze Insel:** bei Berührung durch einen wachen, beweglichen
Körper, beim Greifen, beim Umschalten der Schwerkraft und wenn ein Körper der Insel
gelöscht wird.
Warum Inseln statt einzelner Körper? Sonst könnte ein schlafender Körper unter einem wachen
liegen. Der wache weckt ihn, beide ruhen, einer schläft früher ein, der andere weckt ihn
wieder – ein endloses Hin und Her.
Warum wacht die ganze Insel auf? Zwei schlafende Körper werden nicht auf Kontakte
untereinander geprüft. Wird die unterste Kiste eines schlafenden Stapels geweckt und im
selben Schritt weggestoßen – oder im Pausenmodus weggezogen –, bemerkte sonst niemand, dass
die Kisten darüber ihre Stütze verloren haben. Sie schliefen in der Luft weiter. Weil alle
Körper, die einander berühren, gemeinsam eingeschlafen sind, genügt es, ihre Insel
gemeinsam zu wecken.
Zusammen mit Schwellwert (Abschnitt 7), Slop (Abschnitt 10), Warm-Starting (Abschnitt 6)
und Rollwiderstand (Abschnitt 9) kommen Stapel und Haufen vollständig zur Ruhe. Ein Test
lässt 60 zufällige Körper fallen und verlangt, dass nach 8 s alle schlafen.
**Grenze des Verfahrens:** Wie jeder Schlafmodus mit Geschwindigkeitsschwellen kann er
eine Lage einfrieren, die kein echtes Gleichgewicht ist. Ein Beispiel: Ein Kreis liegt
wenige Pixel außermittig auf einem anderen Kreis. Ist die Kontaktnormale steiler geneigt,
als der Rollwiderstand hält (tan α > μ_r), rollt er ab. Anfangs so langsam, dass er
0,5 s lang unter 4 px/s bleibt und einschläft. Er bleibt dann liegen, bis ihn etwas
berührt, und rollt erst danach weiter. Die Auswirkung beschränkt sich auf wenige Pixel.
Strengere Schwellen würden solche Fälle seltener machen, ließen dafür aber auch ruhende
Haufen später einschlafen.
## 12. Tunneln verhindern
Die Kollisionserkennung ist diskret: Sie sieht nur die Positionen am Ende jedes Schritts.
- **Wände** sind Halbebenen und damit ohnehin tunnelsicher (Abschnitt 5).
- **Körper untereinander:** Fliegen zwei der kleinsten Körper (Radius `MIN_BODY_SIZE` =
12 px) frontal aufeinander zu, darf ihre Relativbewegung pro Schritt die Summe ihrer
Radien nicht übersteigen. Sonst springen die Mittelpunkte aneinander vorbei:
```text
2·v·dt < 2·MIN_BODY_SIZE ⇒ v < MIN_BODY_SIZE / dt = 1440 px/s
```
Mit Sicherheitsfaktor 0,9 ergibt das `MAX_SPEED ≈ 1296 px/s`. Jede Geschwindigkeit wird
nach dem Lösen der Impulse darauf begrenzt, ebenso die Wurfgeschwindigkeit. Aus demselben
Grund ist bei Rechtecken keine *halbe* Kantenlänge kleiner als `MIN_BODY_SIZE`, jede
Seite ist also mindestens 24 px lang.
- Auch die Winkelgeschwindigkeit ist begrenzt (`MAX_SPEED / MIN_BODY_SIZE`).
## 13. Ziehen und Werfen
Ein gezogener Körper wird **nicht** an die Mausposition teleportiert. Dann könnte er durch
andere Körper und Wände hindurchgeschoben werden. Stattdessen setzt `DragController` vor
jedem Physikschritt die Geschwindigkeit in Richtung Ziel:
```text
v = (Ziel − Position) · rate (rate = 60/s; rate·dt ≤ 1 verhindert Überschwingen)
```
Danach löst der Solver die Kontakte ganz normal. Das gezogene Objekt **kollidiert also
weiterhin**: Leichtere Körper werden weggeschoben, Wände und schwere Stapel bremsen es. Das
Ziel wird so geklemmt, dass der Körper ganz in die Welt passt. Gehaltene Körper unterliegen
keiner Schwerkraft ("die Hand trägt sie"), prallen nicht ab ("die Hand fängt den Stoß ab",
siehe [Abschnitt 7](#7-restitution-elastizität)) und schlafen nie ein. Beim Greifen wird
die Schlaf-Insel des Körpers geweckt.
Folge der fehlenden Restitution: Schlägt man mit einem gehaltenen Objekt einen Ball, stößt
es ihn wie ein unelastischer Schläger. Der Ball fliegt deshalb etwas langsamer davon als
bei einem freien, elastischen Stoß (in einer Messung des Reviews um etwa 13 %). Dafür
steht ein gegen ein Hindernis gedrücktes Objekt ruhig.
**Werfen:** Beim Loslassen erhält der Körper die Mausgeschwindigkeit der letzten Frames.
`VelocityTracker` glättet sie in zwei Stufen:
1. **Zeitfenster:** mittlere Geschwindigkeit über die letzten 0,1 s (Weg / Zeit zwischen
ältester und neuester Probe) – unabhängig von der Bildrate.
2. **Exponentielle Glättung:** `v ← v + (v_roh − v)·(1 − e^(−Δt/τ))` mit τ = 0,05 s. Der
Exponentialterm macht die Glättung ebenfalls bildratenunabhängig.
Hält man die Maus vor dem Loslassen still, klingt die Geschwindigkeit schnell ab, und das
Objekt fällt einfach herunter. Im Pausenmodus wird direkt verschoben und nicht geworfen.
## 14. Parameterübersicht
Alle Werte stehen in [`config.py`](../src/sandbox/config.py) und lassen sich dort anpassen.
| Parameter | Wert | Bedeutung |
|---|---|---|
| `gravity` | (0, 980) px/s² | Schwerkraft |
| `time_step` | 1/120 s | fester Zeitschritt |
| `velocity_iterations` | 8 | Solver-Durchläufe pro Schritt |
| `position_iterations` | 2 | Durchläufe der Positionskorrektur |
| `restitution_threshold` | 50 px/s | darunter kein Abprallen |
| `penetration_slop` | 0,5 px | geduldete Resttiefe |
| `position_correction_factor` | 0,4 | Anteil der Korrektur pro Durchlauf |
| `max_position_correction` | 6 px | Obergrenze der Korrektur pro Durchlauf |
| `max_speed` | ≈ 1296 px/s | Tunnel-Schutz |
| `rolling_resistance` | 0,1 | Rollwiderstandsbeiwert μ_r |
| `sleep_linear_speed` / `sleep_angular_speed` | 4 px/s / 0,1 rad/s | Ruheschwellen |
| `time_to_sleep` | 0,5 s | Ruhezeit bis zum Einschlafen |
| `wall_friction` / `wall_restitution` | 0,6 / 0 | Wandmaterial |
| `max_wall_penetration` | 12 px | Notbremse an den Wänden (> `MAX_SPEED · dt`) |
+466
View File
@@ -0,0 +1,466 @@
# Physik für Einsteiger – wie die Sandbox funktioniert
Dieses Dokument erklärt die Physik der Sandbox Schritt für Schritt und ohne Vorwissen.
Jeder Abschnitt sagt, **was** physikalisch passiert, **warum** das Programm es so macht und
**wo** es im Code steht. Die kompakte Formelsammlung für Fortgeschrittene ist
[PHYSICS.md](PHYSICS.md).
## Inhalt
1. [Die Grundidee: Die Welt als Daumenkino](#1-die-grundidee-die-welt-als-daumenkino)
2. [Vektoren: Pfeile mit Richtung und Länge](#2-vektoren-pfeile-mit-richtung-und-länge)
3. [Position, Geschwindigkeit, Beschleunigung](#3-position-geschwindigkeit-beschleunigung)
4. [Masse und Trägheit](#4-masse-und-trägheit)
5. [Der Simulationsschritt: Euler-Verfahren](#5-der-simulationsschritt-euler-verfahren)
6. [Warum ein fester Zeitschritt?](#6-warum-ein-fester-zeitschritt)
7. [Kollisionen erkennen](#7-kollisionen-erkennen)
8. [Kollisionen auflösen: Impulse](#8-kollisionen-auflösen-impulse)
9. [Elastizität: Wie stark etwas abprallt](#9-elastizität-wie-stark-etwas-abprallt)
10. [Reibung](#10-reibung)
11. [Drehung und Rollen](#11-drehung-und-rollen)
12. [Viele Kontakte gleichzeitig](#12-viele-kontakte-gleichzeitig)
13. [Wenn Körper ineinanderstecken: Positionskorrektur](#13-wenn-körper-ineinanderstecken-positionskorrektur)
14. [Ruhe: Warum nichts zittert](#14-ruhe-warum-nichts-zittert)
15. [Tunneln: Warum nichts durch Wände fliegt](#15-tunneln-warum-nichts-durch-wände-fliegt)
16. [Ziehen und Werfen mit der Maus](#16-ziehen-und-werfen-mit-der-maus)
17. [Alles zusammen: ein kompletter Schritt](#17-alles-zusammen-ein-kompletter-schritt)
18. [Zum Ausprobieren](#18-zum-ausprobieren)
---
## 1. Die Grundidee: Die Welt als Daumenkino
Ein Computer kann Bewegung nicht "fließend" berechnen. Er macht es wie ein Daumenkino: Er
berechnet viele Einzelbilder dicht hintereinander. Die Sandbox berechnet die Welt
**120-mal pro Sekunde**. Zwischen zwei Bildern liegt also die Zeit
```text
dt = 1/120 s ≈ 0,0083 s ("dt" = Delta t = kleines Zeitstück)
```
In jedem dieser Schritte fragt das Programm für jeden Körper:
1. Welche Kräfte wirken? (Schwerkraft)
2. Wie ändert sich dadurch die Geschwindigkeit?
3. Wie ändert sich dadurch die Position?
4. Stößt der Körper irgendwo an? Wenn ja: Wie reagieren beide Körper?
Mehr ist Physik-Simulation im Kern nicht. Der Rest dieses Dokuments erklärt die einzelnen
Punkte genauer.
## 2. Vektoren: Pfeile mit Richtung und Länge
Viele Größen in der Physik haben nicht nur einen Betrag, sondern auch eine **Richtung**.
"Der Ball fliegt 5 m/s schnell" reicht nicht – man muss wissen, *wohin*. Deshalb rechnet man
mit **Vektoren**: Pfeilen mit x- und y-Anteil.
```text
v = (3, 4) bedeutet: 3 nach rechts, 4 nach unten (y zeigt wie am Bildschirm nach unten)
```
Die wichtigsten Operationen (alle in [`physics/vector.py`](../src/sandbox/physics/vector.py)):
| Operation | Rechnung | Bedeutung |
|---|---|---|
| Addition `a + b` | (a.x+b.x, a.y+b.y) | Pfeile aneinanderhängen: "erst 3 nach rechts, dann 2 nach oben" |
| Skalierung `a * 2` | (2·a.x, 2·a.y) | Pfeil doppelt so lang, gleiche Richtung |
| Länge `a.length()` | √(x² + y²) | Satz des Pythagoras: wie lang ist der Pfeil? (3,4) hat Länge 5 |
| Normalisieren `a.normalized()` | a / Länge | Pfeil auf Länge 1 bringen – übrig bleibt nur die *Richtung* |
| Skalarprodukt `a.dot(b)` | a.x·b.x + a.y·b.y | "Wie viel von a zeigt in Richtung b?" |
| Kreuzprodukt `a.cross(b)` | a.x·b.y − a.y·b.x | "Wie stark dreht a um b herum?" (für Drehungen) |
**Das Skalarprodukt ist der wichtigste Trick der ganzen Engine.** Ist `n` ein Pfeil der
Länge 1 (z. B. "senkrecht vom Boden weg"), dann gibt `v.dot(n)` an, wie schnell sich etwas
*in Richtung n* bewegt. Ein Ball mit `v = (5, 8)` (schräg nach unten) und der
Boden-Richtung `n = (0, 1)` (nach unten) liefert `v·n = 8`: Er fällt mit 8 Einheiten pro
Sekunde auf den Boden zu. Die 5 nach rechts spielen für den Aufprall keine Rolle.
## 3. Position, Geschwindigkeit, Beschleunigung
Drei Größen hängen wie eine Kette zusammen:
| Größe | Einheit hier | Was sie ändert |
|---|---|---|
| Position x | px | – |
| Geschwindigkeit v | px/s | die Position: "so viele Pixel pro Sekunde" |
| Beschleunigung a | px/s² | die Geschwindigkeit: "pro Sekunde wird man so viel schneller" |
**Schwerkraft** ist eine Beschleunigung. Auf der Erde sind das 9,81 m/s². Die Sandbox rechnet
in Pixeln und nimmt an, dass 100 px einem Meter entsprechen, also:
```text
g = (0, 980) px/s² → pro Sekunde wird ein fallender Körper 980 px/s schneller
```
Beispiel: Ein Ball, der aus der Ruhe fällt, ist nach 0,5 s 490 px/s schnell und hat
½·g·t² = ½ · 980 · 0,25 ≈ 122 px zurückgelegt. Genau das prüft der Test
`test_free_fall_matches_half_g_t_squared`.
## 4. Masse und Trägheit
**Masse** sagt, wie schwer es ist, einen Körper zu beschleunigen. Ein Stoß, der einen
Tischtennisball wegschießt, bewegt eine Bowlingkugel kaum. In der Sandbox gilt:
```text
Masse = Fläche × Dichte
Kreis: Fläche = π · r²
Rechteck: Fläche = Breite · Höhe
```
Ein doppelt so großer Kreis (doppelter Radius) hat die vierfache Fläche und damit die
vierfache Masse.
**Trägheitsmoment** ist dasselbe Prinzip für Drehungen: Wie schwer ist es, einen Körper in
Drehung zu versetzen? Für eine Scheibe gilt `I = ½ · m · r²`. Masse, die weit außen sitzt,
dreht sich schwerer an. Deshalb steht dort das r².
Rechtecke drehen sich in dieser Sandbox **nie** (die Aufgabe verlangt achsenparallele
Rechtecke). Programmtechnisch bekommen sie ein unendliches Trägheitsmoment. Dann kann kein
noch so großer Stoß sie drehen.
**Trick mit dem Kehrwert:** Der Code speichert zusätzlich `inv_mass = 1/Masse`. Wände
bekommen `inv_mass = 0`, das heißt "unendlich schwer". So braucht man für Wände keine
Sonderfälle: Die Rechnung "Geschwindigkeitsänderung = Stoß · inv_mass" ergibt bei einer Wand
automatisch 0 – sie bewegt sich nicht.
## 5. Der Simulationsschritt: Euler-Verfahren
Wie kommt man von "Beschleunigung" zu "neuer Position"? Mit dem einfachsten Verfahren der
Welt, benannt nach Leonhard Euler:
```text
neue Geschwindigkeit = alte Geschwindigkeit + Beschleunigung · dt
neue Position = alte Position + neue Geschwindigkeit · dt
```
Beispiel mit dt = 1/120 s für einen fallenden Ball:
| Schritt | Geschwindigkeit (px/s) | Position y (px) |
|---|---|---|
| 0 | 0 | 100,00 |
| 1 | 0 + 980/120 = 8,17 | 100 + 8,17/120 = 100,07 |
| 2 | 16,33 | 100,20 |
| 3 | 24,50 | 100,41 |
**Wichtiges Detail:** In der zweiten Zeile steht die *neue* Geschwindigkeit. Das heißt
"semi-impliziter Euler". Die naheliegende Variante mit der *alten* Geschwindigkeit hat einen
fiesen Fehler: Sie erzeugt bei Schwingungen und Pendelbewegungen langsam immer mehr
Energie, bis die Simulation "explodiert". Die hier verwendete Variante kostet genauso wenig,
bleibt aber stabil. Im Code: `World._integrate_velocities` und `World._integrate_positions`
in [`physics/world.py`](../src/sandbox/physics/world.py).
## 6. Warum ein fester Zeitschritt?
Das Bild wird z. B. 60-mal pro Sekunde gezeichnet, aber nicht exakt regelmäßig. Mal dauert
ein Bild 15 ms, mal 18 ms, und wenn Windows gerade etwas anderes tut, auch einmal 200 ms.
Würde die Physik diese schwankende Zeit als dt benutzen, gäbe es zwei Probleme:
- Die Simulation liefe auf jedem Rechner etwas anders.
- Bei einem Ruckler von 200 ms würde ein Ball in *einem* Schritt 200 px weit springen und
dabei vielleicht durch einen anderen Körper hindurch.
Deshalb gibt es einen **Akkumulator** ("Zeitsparschwein") in
[`physics/timestep.py`](../src/sandbox/physics/timestep.py): Die echte Zeit wird
gesammelt und in festen Portionen von 1/120 s an die Physik ausgezahlt. Bei 60 Bildern pro
Sekunde sind das meist genau 2 Physikschritte pro Bild. Bleibt ein Rest, wartet er auf das
nächste Bild.
Beim Zeichnen wird zusätzlich zwischen dem vorletzten und dem letzten Physikzustand
**interpoliert** (Zwischenwert gebildet). Dadurch wirkt die Bewegung flüssig, auch wenn
Physik und Bildschirm nicht im selben Takt laufen.
## 7. Kollisionen erkennen
Nach jedem Schritt muss das Programm herausfinden, welche Körper sich überlappen. Für jede
Berührung erzeugt es einen **Kontakt** mit drei Angaben:
- **Normale n**: ein Pfeil der Länge 1, der von Körper A zu Körper B zeigt. In diese
Richtung werden die Körper auseinandergedrückt.
- **Eindringtiefe**: wie weit sich die Körper überlappen.
- **Kontaktpunkt**: wo sie sich berühren.
### Kreis gegen Kreis – der einfachste Fall
Zwei Kreise berühren sich, wenn der Abstand ihrer Mittelpunkte kleiner ist als die Summe
der Radien:
```text
A ●───────● B Abstand d = 30, Radien 20 + 20 = 40
╲___╱ ╲___╱ → Überlappung 40 − 30 = 10 px
Normale n = (B − A) / d → Pfeil von A nach B, Länge 1
```
### Kreis gegen Rechteck
Man sucht den Punkt des Rechtecks, der dem Kreismittelpunkt am nächsten liegt. Dafür
"klemmt" man die Kreismitte in das Rechteck: Liegt sie links vom Rechteck, nimmt man den
linken Rand, liegt sie darüber, den oberen Rand usw. Ist dieser nächste Punkt näher als der
Radius, berühren sich die beiden.
### Rechteck gegen Rechteck
Zwei achsenparallele Rechtecke überlappen genau dann, wenn sie sich **sowohl** in
x-Richtung **als auch** in y-Richtung überlappen. Getrennt werden sie entlang der Richtung
mit der kleineren Überlappung – das ist der kürzeste Weg nach draußen.
### Wände
Boden, Decke und Seiten sind "Halbebenen": Alles hinter der Wandlinie gilt als Wand. Der
Vorteil: Auch ein Körper, der in einem Schritt komplett hinter die Wand gesprungen ist, wird
noch erkannt und zurückgeschoben.
### Schneller suchen: Broadphase
Bei 150 Körpern gibt es über 11 000 mögliche Paare – zu viele, um jedes genau zu prüfen.
Deshalb sortiert [`physics/broadphase.py`](../src/sandbox/physics/broadphase.py) alle Körper
von links nach rechts. Zwei Körper, die in x-Richtung weit auseinanderliegen, können sich
nicht berühren und werden gar nicht erst genau geprüft ("Sweep-and-Prune").
## 8. Kollisionen auflösen: Impulse
Jetzt der wichtigste Teil. Zwei Körper berühren sich und bewegen sich aufeinander zu. Was
passiert?
### Impuls – "Wucht"
Der **Impuls** eines Körpers ist Masse mal Geschwindigkeit: `p = m · v`. Ein schwerer,
langsamer Lkw und ein leichter, schneller Ball können gleich viel "Wucht" haben.
Ein **Stoß** (ebenfalls Impuls genannt, Formelzeichen P) ist eine sehr kurze, starke Kraft,
etwa beim Aufprall. Er ändert die Geschwindigkeit sofort:
```text
Geschwindigkeitsänderung = Stoß / Masse
```
### Actio = Reactio
Newtons drittes Gesetz: Wenn A gegen B drückt, drückt B genauso stark gegen A zurück. Der
Stoß P wirkt also auf B in Richtung der Normalen und auf A genau entgegengesetzt:
```text
v_A neu = v_A − P · n / m_A
v_B neu = v_B + P · n / m_B
```
Dadurch bleibt der Gesamtimpuls erhalten. Was A an Wucht verliert, gewinnt B.
### Wie groß muss der Stoß sein?
Man schaut, wie schnell sich die beiden Körper aufeinander zu bewegen – entlang der
Normalen, also mit dem Skalarprodukt aus Abschnitt 2:
```text
v_n = (v_B − v_A) · n (negativ = sie nähern sich an)
```
Dann sucht man den Stoß P, der diese Annäherung genau stoppt (bzw. umkehrt, siehe nächster
Abschnitt). Weil der Stoß beide Körper beeinflusst, hängt die nötige Stärke von beiden
Massen ab:
```text
P = −(1 + e) · v_n / (1/m_A + 1/m_B)
```
Den Nenner `1/(1/m_A + 1/m_B)` nennt man **effektive Masse**. Bei einer Wand
(`1/m = 0`) ist sie einfach die Masse des Körpers. Bei zwei gleich schweren Körpern ist sie
halb so groß, weil der andere Körper mit ausweicht.
**Rechenbeispiel:** Eine Kugel (m = 1) fliegt mit 10 px/s senkrecht auf den Boden, der
völlig unelastisch ist (e = 0). Dann ist v_n = −10, die effektive Masse ist 1 (Wand), und
der Stoß ist P = −(1 + 0) · (−10) / 1 = 10. Die neue Geschwindigkeit ist 10 − 10 = 0: Die
Kugel bleibt liegen.
Im Code: [`physics/solver.py`](../src/sandbox/physics/solver.py), die ausführliche
Herleitung steht am Dateianfang.
## 9. Elastizität: Wie stark etwas abprallt
Die **Stoßzahl e** (in der Sidebar "Elastizität") sagt, wie viel der Geschwindigkeit nach
dem Aufprall übrig bleibt:
| e | Verhalten | Beispiel |
|---|---|---|
| 0 | bleibt kleben, prallt nicht ab | Knetmasse |
| 0,5 | prallt mit halber Geschwindigkeit ab | Holzklotz |
| 0,8 | springt kräftig zurück | Gummiball |
| 1 | prallt ohne Verlust ab | ideale Superkugel |
Daraus folgt eine schöne Regel: Ein Ball springt nach dem Aufprall auf **e²** seiner
Fallhöhe. Mit e = 0,8 sind das 0,64, also 64 % der Höhe. (Die Energie hängt vom Quadrat der
Geschwindigkeit ab, deshalb das Quadrat.)
Zwei praktische Sonderregeln:
- **Unter 50 px/s kein Abprallen.** Ein Körper, der auf dem Boden liegt, "fällt" wegen der
Schwerkraft in jedem Schritt minimal in den Boden. Würde er davon jedes Mal ein winziges
bisschen abprallen, würde er ständig zittern.
- **Gehaltene Objekte prallen nicht ab.** Die "Hand" fängt den Stoß ab – sonst würde ein
gegen eine Wand gedrücktes Objekt dauernd zurückfedern.
## 10. Reibung
Reibung bremst, wenn zwei Flächen aneinander entlangrutschen. Sie wirkt **quer zur
Normalen**, also entlang der Berührfläche (dieser Richtung sagt man "Tangente").
Das Coulomb-Gesetz: Die Reibung kann höchstens so stark sein wie der Andruck mal dem
**Reibungskoeffizienten μ**:
```text
Reibung ≤ μ · Andruck
```
- Ist wenig Schwung da, reicht die Reibung, um die Bewegung ganz zu stoppen: **Haftreibung**.
Eine Kiste bleibt auf dem Boden stehen.
- Ist mehr Schwung da, rutscht die Kiste trotz Reibung weiter und wird gleichmäßig langsamer:
**Gleitreibung**. Sie bremst mit `μ · g`. Eine Kiste mit 300 px/s und μ ≈ 0,5 rutscht
etwa 90 px weit.
Der Test `test_sliding_box_decelerates_with_coulomb_friction` prüft genau diesen Bremsweg.
## 11. Drehung und Rollen
Kreise können sich drehen. Die **Winkelgeschwindigkeit ω** (Omega) sagt, wie schnell, in
Radiant pro Sekunde (eine volle Umdrehung sind 2π ≈ 6,28 rad).
Warum dreht sich ein Kreis überhaupt? Die Reibung greift nicht in der Mitte an, sondern
unten am Kontaktpunkt. Ein Stoß, der nicht durch die Mitte geht, erzeugt ein
**Drehmoment**, ähnlich wie ein Schraubenschlüssel. Genau dafür braucht man das
Kreuzprodukt aus Abschnitt 2: `Hebelarm × Kraft` ergibt, wie stark gedreht wird.
**Rollen** heißt: Der Kontaktpunkt steht still, der Ball dreht sich genau passend zu seiner
Geschwindigkeit:
```text
v = ω · r
```
Ein Experiment, das die Sandbox exakt nachrechnet: Stößt man eine Scheibe an, sodass sie
ohne Drehung über den Boden rutscht, bremst die Reibung sie und bringt sie gleichzeitig zum
Drehen. Irgendwann rollt sie – und dann hat sie **genau ⅔ ihrer Startgeschwindigkeit**,
unabhängig davon, wie stark die Reibung ist. Den kleinen Strich in jedem Kreis zeichnet das
Programm, damit man die Drehung sehen kann.
**Rollwiderstand:** Ein perfekt rollender Ball würde nie anhalten, denn reines Rollen
verbraucht keine Reibungsenergie. In der Realität verformen sich Ball und Boden ein wenig,
das bremst. Die Sandbox bildet das mit einem kleinen Gegen-Drehmoment nach. Ohne diese
Bremse würde ein Rechteck, das auf einem Kreis liegt, darauf wie auf einer Rolle
davonfahren.
## 12. Viele Kontakte gleichzeitig
Liegt eine Kiste in einem Stapel, hat sie Kontakte nach oben und nach unten. Jeder Stoß an
einem Kontakt verändert die Situation an den anderen Kontakten. Man kann sie nicht einzeln
nacheinander "fertig" rechnen.
Die Lösung heißt **Sequential Impulses** und funktioniert wie beim Aufräumen eines Zimmers
in mehreren Runden: Man geht alle Kontakte durch und korrigiert jeden ein bisschen. Dann
geht man sie noch einmal durch, weil die Korrekturen sich gegenseitig beeinflusst haben.
Nach **8 Runden** pro Schritt ist das Ergebnis sehr genau.
Zwei Tricks machen das Verfahren stabil:
- **Kontakte können nur drücken, nicht ziehen.** Die Summe aller Stöße an einem Kontakt darf
nie negativ werden, sonst würden Körper aneinanderkleben.
- **Warm-Starting:** Ein ruhender Stapel sieht im nächsten Schritt fast genauso aus wie
jetzt. Deshalb startet man mit den Stößen des letzten Schritts statt bei null und ist
sofort fast fertig.
## 13. Wenn Körper ineinanderstecken: Positionskorrektur
Weil nur in festen Schritten gerechnet wird, landet ein fallender Ball meist schon ein
kleines Stück *im* Boden, bevor der Aufprall erkannt wird. Die Stöße korrigieren nur die
Geschwindigkeit, nicht die Überlappung. Ohne Gegenmaßnahme würden Stapel langsam
ineinandersinken.
Deshalb schiebt der Solver die Körper nach jedem Schritt ein Stück auseinander – direkt über
ihre Position, **ohne** ihnen Geschwindigkeit zu geben. Würde man sie stattdessen
"wegschubsen", würden stark überlappende Körper (zum Beispiel, wenn man ein Objekt mitten
in einen Haufen setzt) davonfliegen.
Eine winzige Überlappung von 0,5 px (der "Slop") lässt man absichtlich stehen. So bleibt der
Kontakt im nächsten Schritt sicher bestehen, statt ständig zwischen "berührt" und "berührt
nicht" zu wechseln – auch das wäre sichtbares Zittern.
## 14. Ruhe: Warum nichts zittert
Auch mit allen Tricks bleiben winzige Restbewegungen übrig. Die Lösung ist der
**Schlafmodus** ([`physics/sleep.py`](../src/sandbox/physics/sleep.py)):
1. Jeder Körper zählt, wie lange er schon fast stillsteht (langsamer als 4 px/s).
2. Körper, die sich berühren, bilden eine Gruppe (eine "Insel").
3. Hat **die ganze Insel** eine halbe Sekunde lang fast stillgestanden, wird sie
eingefroren: Sie bewegt sich nicht mehr und kostet keine Rechenzeit.
4. Stößt etwas gegen einen schlafenden Körper, wacht **die ganze Insel** wieder auf.
Warum die ganze Insel? Weckt man nur die unterste Kiste eines Stapels und zieht sie weg,
würde die Kiste darüber sonst in der Luft weiterschlafen. Genau diesen Fehler hat das
unabhängige Review gefunden, und genau so wurde er behoben.
## 15. Tunneln: Warum nichts durch Wände fliegt
Weil nur in Schritten gerechnet wird, könnte ein sehr schneller, kleiner Körper in einem
Schritt komplett über einen anderen hinwegspringen, ohne dass je eine Überlappung auftritt.
Das nennt man **Tunneln**.
Zwei Gegenmaßnahmen:
- **Wände sind unendlich dick** (Halbebenen). Ein Körper kann nicht "hinter" sie gelangen,
ohne erkannt zu werden.
- **Geschwindigkeitsgrenze:** Die kleinsten Körper haben einen Radius von 12 px. Solange
sich zwei davon pro Schritt zusammen weniger als ihre Radien-Summe bewegen, können ihre
Mittelpunkte nicht aneinander vorbeispringen. Daraus ergibt sich eine Höchstgeschwindigkeit
von etwa 1300 px/s, auf die jeder Körper begrenzt wird.
## 16. Ziehen und Werfen mit der Maus
Würde man ein gezogenes Objekt einfach an die Mausposition "teleportieren", könnte man es
durch Wände und andere Objekte hindurchschieben. Stattdessen bekommt es in jedem
Physikschritt die **Geschwindigkeit**, die es zur Maus bringt:
```text
v = (Mausziel − Position) · 60
```
Danach behandelt die Physik es wie jeden anderen Körper. Es schiebt leichte Objekte weg und
wird von Wänden gestoppt.
**Werfen:** Beim Loslassen bekommt das Objekt die Geschwindigkeit der Maus. Die Maus
zittert aber und liefert unregelmäßige Werte. Deshalb wird geglättet
([`interaction/velocity_tracker.py`](../src/sandbox/interaction/velocity_tracker.py)):
erst ein Mittelwert über die letzten 0,1 Sekunden, dann noch eine weiche Glättung. Hält man
die Maus vor dem Loslassen still, sinkt die Geschwindigkeit auf fast null – das Objekt fällt
dann einfach herunter.
## 17. Alles zusammen: ein kompletter Schritt
So sieht ein einzelner Physikschritt (1/120 s) in `World.step` aus:
1. Zustand merken (für das flüssige Zeichnen und das Abprallen).
2. Schwerkraft anwenden: `v = v + g · dt`.
3. Mögliche Kollisionspaare grob vorsortieren (Broadphase).
4. Kontakte genau bestimmen; schlafende Inseln wecken, die berührt werden.
5. Kontakte mit den Wänden bestimmen.
6. Stöße berechnen – 8 Runden lang, mit Abprallen, Reibung und Rollwiderstand.
7. Geschwindigkeit begrenzen (gegen Tunneln).
8. Bewegen: `x = x + v · dt`.
9. Restliche Überlappungen durch Verschieben abbauen.
10. Notbremse: Niemand steckt tiefer als 12 px in einer Wand.
11. Schlafmodus aktualisieren.
## 18. Zum Ausprobieren
Am besten versteht man Physik, indem man damit spielt:
- Setze die **Elastizität auf 1** und lass einen Ball fallen: Er springt immer wieder genau
auf seine Starthöhe. Bei 0,5 wird jeder Sprung deutlich niedriger.
- Wirf einen kleinen Kreis mit **Dichte 0,2** gegen einen großen mit **Dichte 5**: Der
leichte prallt ab, der schwere bewegt sich kaum (Impulserhaltung).
- Baue einen Turm aus Rechtecken und ziehe die unterste Kiste weg: Der Rest fällt.
- Schalte mit **G** die Schwerkraft aus und wirf Objekte: Sie fliegen geradeaus, bis sie
etwas treffen (Newtons erstes Gesetz: Trägheit).
- Drücke **F1**: Gelbe Punkte zeigen die Kontaktpunkte, rote Striche die Normalen, graue
Umrandungen schlafende Körper.
- Ändere Werte in [`config.py`](../src/sandbox/config.py), zum Beispiel `gravity` oder
`rolling_resistance`, und beobachte die Wirkung.
+124
View File
@@ -0,0 +1,124 @@
# Tests
## Tests ausführen
Voraussetzung ist die Entwicklungsumgebung aus dem [README](../README.md):
```bash
pip install -r requirements-dev.txt
```
Alle Tests ausführen (Dauer etwa 15 s):
```bash
pytest
```
Mit Testabdeckung und Liste der nicht abgedeckten Zeilen:
```bash
pytest --cov=sandbox --cov-report=term-missing
```
Nur die schnellen Tests (ohne Stresstest und Smoke-Test):
```bash
pytest -m "not slow"
```
Lint und Formatierung prüfen:
```bash
ruff check src tests
```
```bash
ruff format --check src tests
```
Ruff prüft neben Stil und typischen Fehlern auch, dass alle Module, Klassen und
öffentlichen Funktionen einen Docstring (`D`) und Type Hints (`ANN`) haben und dass in
Vergleichen keine Magic Numbers stehen (`PLR2004`). Die Konfiguration steht in
`pyproject.toml`.
## Headless-Modus
UI- und Integrationstests laufen **ohne sichtbares Fenster**. `tests/conftest.py` setzt vor
dem ersten Import von pygame:
```python
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
```
SDL rendert dann in einen unsichtbaren Puffer und braucht weder Bildschirm noch Soundkarte.
Die Tests laufen so auch in CI oder über SSH.
Die UI-Tests bedienen die Anwendung wie ein Mensch: Sie legen echte pygame-Events
(`MOUSEBUTTONDOWN`, `MOUSEMOTION`, `KEYDOWN` …) in die Event-Queue und rufen dann
`App.frame()` auf. Die Hilfsfunktionen dafür stehen in `tests/app/driver.py`. So wird die
ganze Kette getestet: Event → Sidebar/InputHandler → Werkzeuge → Physik → Zeichnen.
Die Anwendung selbst lässt sich ebenfalls headless starten, etwa für Screenshots.
PowerShell:
```powershell
$env:SDL_VIDEODRIVER = "dummy"; python -m sandbox --demo --screenshot bild.png --seconds 2
```
Bash:
```bash
SDL_VIDEODRIVER=dummy python -m sandbox --demo --screenshot bild.png --seconds 2
```
## Was wird getestet?
Die Erwartungswerte der Physik-Tests stammen aus der klassischen Mechanik –
Impulserhaltung, Stoßgesetz, Coulomb-Reibung, Rollbedingung – und nicht aus der
Implementierung selbst.
### Physik (`tests/physics/`, ohne pygame)
| Datei | Inhalt |
|---|---|
| `test_vector.py` | alle Vektoroperationen, Nullvektor-Normalisierung ohne NaN, Unveränderlichkeit, Hash/Gleichheit, Kopieren/Pickle |
| `test_bodies.py` | Fläche und Trägheitsmoment, **Masse = Fläche × Dichte**, statische Körper, Materialprüfung, Impulse mit/ohne Drehung, Energie und Impuls |
| `test_collision.py` | jede Formpaarung mit Normalenrichtung, Tiefe und Kontaktpunkt; Randfälle (gleicher Mittelpunkt, Kreismittelpunkt im Rechteck, knapp verfehlte Ecke, Körper hinter der Wand); Zufallstest mit über 500 Kontakten: Verschieben um die Tiefe trennt die Körper |
| `test_broadphase.py` | Sweep-and-Prune liefert dieselben Paare wie der Brute-Force-Vergleich |
| `test_solver.py` | **elastischer Stoß:** Impuls- und Energieerhaltung, Lehrbuchformel; e = 0: gemeinsame Geschwindigkeit; **Energieverlust bei e < 1** exakt nach ΔE = ½·μ·(1 − e²)·v²; Restitutionsschwelle; schiefer Stoß ohne Reibung; Coulomb-Reibung (Bremsweg v²/2μg); Rutschen → Rollen mit ⅔·v₀; Rollwiderstand; Auflagekraft = m·g·dt; Impulsgrenzen; **Trennen überlappender Körper ohne Geschwindigkeit** |
| `test_world.py` | **freier Fall ≈ ½·g·t²** (und exakt nach semi-implizitem Euler); **Ball bleibt am Boden liegen und verlässt die Welt nie**; Sprunghöhe = e² · Fallhöhe (1 %); ein vollkommen elastischer Ball springt über viele Sprünge immer wieder genau auf seine Fallhöhe; Energie steigt ohne äußere Kräfte nie; Stapel bleibt stehen; gemischter Haufen schläft ein; kein Tunneln bei Höchstgeschwindigkeit; Notbremse; Schlafen/Aufwecken, auch wenn die Basis eines schlafenden Stapels weggezogen oder weggestoßen wird (nichts schläft in der Luft weiter); Determinismus; **100 Körper × 1000 Schritte ohne NaN** (Stresstest) |
| `test_timestep.py` | Akkumulator, keine verlorenen Schritte durch Rundung, Obergrenze gegen die "Spiral of Death" |
| `test_sleep.py` | Union-Find, Inselbildung (Wände verbinden nicht), Einschlafen und Aufwachen nur als ganze Insel |
### Interaktion (`tests/interaction/`, ohne pygame)
| Datei | Inhalt |
|---|---|
| `test_velocity_tracker.py` | exakte Messung konstanter Bewegung, Unabhängigkeit von der Bildrate, Abklingen beim Stillhalten, Glättung von Zittern |
| `test_drag_and_tools.py` | Seitenverhältnis mit gleicher Fläche, Mindestgröße, Körper erzeugen, Obergrenze, Vorschaufarbe = echte Farbe; Greifen mit Versatz, Ziel in der Welt, Folgen, Geschwindigkeitsgrenze, **gezogener Körper schiebt andere weg**, gegen ein Hindernis gedrückter Körper zittert nicht, Werfen, Pause |
| `test_demo.py` | Beispielszene passt in die Welt, der Turm bleibt stehen |
### Anwendung (`tests/app/`, headless mit pygame)
| Datei | Inhalt |
|---|---|
| `test_app_integration.py` | **Objekt erstellen** (Klick, Ziehen aus der Sidebar, Abbruch), **greifen**, ziehen, **werfen**, Stillhalten ohne Wurf, gezogener Körper schiebt andere, bleibt in der Welt; **löschen** (Rechtsklick, Entf, Alles löschen); Pause, Schwerkraft, Regler (Klick, Ziehen, Mausrad), Tastenkürzel, Fenster schließen, Debug-Ansicht, Screenshot, Kommandozeile |
| `test_widgets.py` | Regler-Abbildung und Rasterung, deaktivierte Regler, Umschalter, Farbwahl, Zeichnen aller Widgets, Viewport, Winkel-Interpolation |
| `test_entry_points_and_edge_cases.py` | `python -m sandbox`, CLI ohne Optionen, Ende der Hauptschleife, Randfälle der Eingabe, Schrift-Fallback |
| `test_smoke.py` | **Smoke-Test:** Die echte Hauptschleife läuft 3 s headless, während ein zufällig klickender "Affe" Klicks, Ziehen, Rechtsklicks, Mausrad und Tasten sendet – ohne Exception, danach ein gültiger Zustand |
### Architektur (`tests/test_architecture.py`)
- Die Physik-Module und `config.py` importieren nur erlaubte Module (statische Prüfung über
den Syntaxbaum).
- `import sandbox.physics` lädt in einem frischen Interpreter kein pygame.
## Testabdeckung
Stand der letzten Messung: **219 Tests, 100 % Zeilen- und Branch-Abdeckung** über das ganze
Paket `sandbox` (inklusive UI), davon 100 % für `sandbox.physics`.
Die hohe Abdeckung ist kein Selbstzweck. Entscheidend sind die physikalischen
Erwartungswerte: Ein Test, der nur Code ausführt, ohne das Ergebnis gegen die Physik zu
prüfen, wäre wertlos.
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB