proejct commit
This commit is contained in:
@@ -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. |
|
||||
@@ -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
@@ -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`) |
|
||||
@@ -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
@@ -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 |
Reference in New Issue
Block a user