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
+11
View File
@@ -0,0 +1,11 @@
.venv/
__pycache__/
*.py[cod]
*.egg-info/
build/
dist/
.pytest_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
+193
View File
@@ -0,0 +1,193 @@
# Physik-Sandbox
Interaktive 2D-Physiksimulation in Python. Über eine Werkzeugleiste erzeugst du Kreise und
Rechtecke unterschiedlicher Größe, greifst sie mit der Maus, ziehst sie herum und wirfst
sie gegeneinander. Die Körper fallen, prallen ab, reiben, rollen, stapeln sich und kommen
stabil zur Ruhe.
Die **gesamte Physik ist selbst geschrieben** – Vektormathematik, Integration,
Kollisionserkennung und impulsbasierte Kollisionsauflösung verwenden ausschließlich das
Standardmodul `math`. [pygame-ce](https://pyga.me/) dient nur für Fenster, Zeichnen,
Eingaben und Zeitmessung.
![Demo-Szene: Ein Rechteck wird gerade geworfen, daneben Kreispyramide und Rechteckturm](docs/images/screenshot.png)
## Funktionen
- Kreise und achsenparallele Rechtecke; Größe, Seitenverhältnis, Elastizität, Dichte und
Farbe in der Sidebar einstellbar – die Masse ergibt sich aus Fläche × Dichte.
- Objekte per Klick in die Welt oder per Ziehen aus der Vorschau-Kachel erzeugen.
- Drag & Drop mit Kollisionen: Ein gezogenes Objekt schiebt andere weg und wird von Wänden
gestoppt. Beim Loslassen erhält es die geglättete Mausgeschwindigkeit (Werfen).
- Schwerkraft, Kreis–Kreis-, Kreis–Rechteck-, Rechteck–Rechteck- und Wandkollisionen,
Restitution, Coulomb-Reibung, Rollen und Rollwiderstand.
- Fester Zeitschritt (120 Hz), semi-impliziter Euler, Warm-Starting, Positionskorrektur,
Schlafmodus für ruhende Körper, Geschwindigkeitsgrenze gegen Tunneln.
- Pause, Schwerkraft an/aus, Alles löschen, Debug-Ansicht mit Kontaktpunkten.
## Voraussetzungen
- Python **3.10 oder neuer** (entwickelt und getestet mit Python 3.14)
- Windows, macOS oder Linux
> **Warum pygame-ce?** Das Original-`pygame` (2.6.1) bietet keine fertigen Pakete für
> Python 3.14. `pygame-ce` ist der API-kompatible Community-Fork; im Code heißt es weiterhin
> `import pygame`.
## Einrichtung
Im Projektordner eine virtuelle Umgebung anlegen und die Abhängigkeiten installieren:
```bash
python -m venv .venv
```
Aktivieren – unter Windows (PowerShell):
```powershell
.venv\Scripts\Activate.ps1
```
Meldet PowerShell "Die Ausführung von Skripts ist auf diesem System deaktiviert", erlaube
lokale Skripte einmalig für deinen Benutzer (danach die Aktivierung wiederholen):
```powershell
Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
```
Alternativ ohne Aktivierung: Ersetze in allen folgenden Befehlen
| Befehl | durch |
|---|---|
| `python` | `.venv\Scripts\python.exe` |
| `pip` | `.venv\Scripts\python.exe -m pip` |
| `pytest` | `.venv\Scripts\python.exe -m pytest` |
| `ruff` | `.venv\Scripts\ruff.exe` |
| `physics-sandbox` | `.venv\Scripts\physics-sandbox.exe` |
Unter macOS/Linux:
```bash
source .venv/bin/activate
```
Dann installieren:
```bash
pip install -r requirements.txt
```
`requirements.txt` installiert `pygame-ce` und das Projekt selbst im Editable-Modus
(`-e .`). Den Befehl deshalb im Projektordner ausführen.
## Starten
```bash
python -m sandbox
```
Weitere Möglichkeiten:
| Befehl | Wirkung |
|---|---|
| `python -m sandbox --demo` | startet mit einer Beispielszene (Turm, Kreispyramide, Gummibälle) |
| `physics-sandbox` | gleichwertiger Befehl, den die Installation anlegt |
| `python -m sandbox --demo --screenshot bild.png --seconds 3` | läuft 3 s, speichert ein Bildschirmfoto und beendet sich |
## Bedienung (Kurzfassung)
| Eingabe | Aktion |
|---|---|
| Linksklick ins Leere | Objekt erzeugen (gedrückt halten und ziehen = sofort werfen) |
| Linksklick auf Objekt + Ziehen | greifen und verschieben; Loslassen in Bewegung wirft es |
| Ziehen aus der Vorschau-Kachel | neues Objekt aus der Sidebar in die Welt ziehen |
| Rechtsklick / `Entf` | Objekt unter der Maus löschen |
| Mausrad | Größe ändern (über einem Regler: diesen Regler verstellen) |
| `Leertaste` · `G` · `C` | Pause/Weiter · Schwerkraft an/aus · Alles löschen |
| `1` · `2` | Kreis · Rechteck |
| `F1` · `Esc` | Debug-Ansicht · Beenden |
Alle Details: [docs/CONTROLS.md](docs/CONTROLS.md).
## Entwicklung
Entwicklungswerkzeuge (pytest, pytest-cov, ruff) installieren:
```bash
pip install -r requirements-dev.txt
```
Tests ausführen (inklusive Headless-UI-Tests – es öffnet sich kein Fenster):
```bash
pytest
```
Mit Testabdeckung:
```bash
pytest --cov=sandbox --cov-report=term-missing
```
Code prüfen und formatieren:
```bash
ruff check src tests
```
```bash
ruff format src tests
```
Details zu den Tests: [docs/TESTING.md](docs/TESTING.md).
## Projektstruktur
```text
├── src/sandbox/
│ ├── main.py Einstiegspunkt, App und Game-Loop
│ ├── config.py alle Konstanten (Physik, Interaktion, Farben, Layout)
│ ├── physics/ eigene Physik-Engine – nur `math`, kein pygame
│ │ ├── vector.py Vector2
│ │ ├── shapes.py Circle, Box (AABB), HalfPlane (Wände)
│ │ ├── body.py Body, Material
│ │ ├── collision.py Kollisionserkennung (Narrowphase)
│ │ ├── broadphase.py Sweep-and-Prune
│ │ ├── solver.py Impulsauflösung, Reibung, Rollwiderstand, Positionskorrektur
│ │ ├── sleep.py Schlafmodus (Kontakt-Inseln)
│ │ ├── timestep.py fester Zeitschritt
│ │ └── world.py Welt und Simulationsschritt
│ ├── interaction/ Werkzeuge ohne pygame: Erzeugen, Ziehen, Werfen
│ ├── ui/ Sidebar und selbst gezeichnete Widgets
│ ├── input_handler.py Maus und Tastatur → Aktionen
│ ├── renderer.py Zeichnen der Welt
│ ├── viewport.py Bildschirm ↔ Welt
│ └── demo.py Beispielszene
├── tests/ pytest-Suite (Physik, Interaktion, Headless-UI, Smoke-Test)
├── docs/ Dokumentation
├── requirements.txt Laufzeit-Abhängigkeiten
├── requirements-dev.txt Test- und Linting-Werkzeuge
└── pyproject.toml Paket-, ruff- und pytest-Konfiguration
```
## Dokumentation
- [docs/PHYSIK_EINSTIEG.md](docs/PHYSIK_EINSTIEG.md) – die Physik Schritt für Schritt für Einsteiger
- [docs/PHYSICS.md](docs/PHYSICS.md) – die verwendete Physik und ihre Formeln
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) – Module, Datenfluss im Game-Loop, Diagramme
- [docs/TESTING.md](docs/TESTING.md) – Tests ausführen, was getestet wird, Headless-Modus
- [docs/CONTROLS.md](docs/CONTROLS.md) – alle Maus- und Tastaturbefehle
## Screenshots
Demo-Szene mit gerade geworfenem Rechteck (gelb umrandet, der Pfeil zeigt die
Wurfgeschwindigkeit):
![Demo-Szene](docs/images/screenshot.png)
Debug-Ansicht (`F1`): Kontaktpunkte (gelb), Kontaktnormalen (rot) und Kennzahlen:
![Debug-Ansicht](docs/images/debug.png)
Ein aktuelles Bild der Demo-Szene erzeugst du jederzeit mit
`python -m sandbox --demo --screenshot docs/images/demo.png`.
+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

+74
View File
@@ -0,0 +1,74 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "physics-sandbox"
version = "1.0.0"
description = "Interaktive 2D-Physik-Sandbox mit eigener Physik-Engine (Rendering mit pygame-ce)"
readme = "README.md"
requires-python = ">=3.10"
dependencies = ["pygame-ce>=2.5"]
[project.scripts]
physics-sandbox = "sandbox.main:main"
[tool.setuptools.packages.find]
where = ["src"]
# --------------------------------------------------------------------------- ruff
[tool.ruff]
line-length = 100
target-version = "py310"
src = ["src", "tests"]
[tool.ruff.lint]
select = [
"E", "W", # pycodestyle
"F", # pyflakes
"I", # Import-Sortierung
"B", # bugbear: typische Fehlerquellen
"UP", # moderne Python-Syntax
"SIM", # Vereinfachungen
"N", # Namenskonventionen
"C4", # Comprehensions
"PIE",
"RUF",
"D", # Docstrings für Module, Klassen und öffentliche Funktionen
"ANN", # Type Hints durchgehend
"PLR2004", # keine Magic Numbers in Vergleichen
]
ignore = [
"D105", # Docstrings für Dunder-Methoden (__add__ usw.) wären reines Rauschen
"D107", # __init__ wird im Klassen-Docstring beschrieben
# Kommentare und Docstrings sind deutsch und nutzen bewusst Formelzeichen (·, ×, ², ω, μ):
"RUF001", "RUF002", "RUF003",
]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.ruff.lint.per-file-ignores]
# Testfunktionen sprechen über ihre Namen für sich; Zahlenwerte in Asserts sind gewollt.
"tests/**" = ["D101", "D102", "D103", "PLR2004"]
# --------------------------------------------------------------------------- pytest
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "--strict-markers"
markers = [
"slow: länger laufende Tests (Stresstest, Smoke-Test der Hauptschleife)",
]
# --------------------------------------------------------------------------- coverage
[tool.coverage.run]
source = ["sandbox"]
branch = true
[tool.coverage.report]
show_missing = true
exclude_also = [
"if __name__ == .__main__.:",
"raise NotImplementedError",
]
+6
View File
@@ -0,0 +1,6 @@
# Entwicklungs-Abhängigkeiten: Tests, Coverage, Linting/Formatierung.
-r requirements.txt
pytest==9.1.1
pytest-cov==7.1.0
ruff==0.16.8
+10
View File
@@ -0,0 +1,10 @@
# Laufzeit-Abhängigkeiten der Physik-Sandbox.
#
# pygame-ce ist der API-kompatible Community-Fork von pygame (Import bleibt `import pygame`).
# Im Gegensatz zu pygame 2.6.1 gibt es dafür fertige Wheels für Python 3.14.
pygame-ce==2.5.8
# Das Projekt selbst (src-Layout) im Editable-Modus installieren, damit `python -m sandbox`
# ohne PYTHONPATH-Anpassung funktioniert. Der Pfad "." bezieht sich auf das aktuelle
# Verzeichnis – den Befehl daher im Projektordner ausführen.
-e .
+18
View File
@@ -0,0 +1,18 @@
"""Physik-Sandbox: interaktive 2D-Physiksimulation mit eigener Physik-Engine.
Aufbau:
* :mod:`sandbox.physics` – reine Python-Physik (nur ``math``), ohne pygame testbar.
* :mod:`sandbox.interaction` – Werkzeug-Logik (Erzeugen, Ziehen, Werfen), ebenfalls
pygame-frei.
* :mod:`sandbox.ui`, :mod:`sandbox.renderer`, :mod:`sandbox.input_handler`,
:mod:`sandbox.main` – Fenster, Zeichnen, Eingaben und Hauptschleife mit pygame-ce.
"""
import os
# pygame-ce gibt beim ersten Import eine Werbezeile aus. Das Paket wird immer vor pygame
# importiert, deshalb lässt sie sich hier zentral abschalten (ohne pygame zu importieren).
os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1")
__version__ = "1.0.0"
+5
View File
@@ -0,0 +1,5 @@
"""Ermöglicht den Start mit ``python -m sandbox``."""
from sandbox.main import main
raise SystemExit(main())
+440
View File
@@ -0,0 +1,440 @@
"""Zentrale Konfiguration der Physik-Sandbox.
Alle einstellbaren Zahlenwerte des Projekts stehen in diesem Modul, damit der übrige Code
frei von "Magic Numbers" bleibt. Zusammengehörige Werte sind in unveränderlichen
Dataclasses gebündelt; Tests variieren einzelne Parameter mit :func:`dataclasses.replace`.
Einheiten: Längen in Pixeln (px), Zeiten in Sekunden (s), Geschwindigkeiten in px/s.
Die y-Achse zeigt wie auf dem Bildschirm nach unten, die Schwerkraft wirkt also in +y.
Das Modul importiert ausschließlich die Standardbibliothek, weil auch die pygame-freie
Physik-Schicht darauf zugreift.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Final
# =============================================================================
# Grundgrößen der Simulation
# =============================================================================
#: Fester Physik-Zeitschritt in Sekunden (120 Hz), unabhängig von der Bildrate.
TIME_STEP: Final = 1.0 / 120.0
#: Kleinste und größte Halbausdehnung eines Körpers in px (Kreisradius bzw. halbe
#: Kantenlänge eines Rechtecks).
MIN_BODY_SIZE: Final = 12.0
MAX_BODY_SIZE: Final = 80.0
#: Sicherheitsabstand (< 1) zur theoretischen Tunnel-Grenze, siehe MAX_SPEED.
TUNNELING_SAFETY_FACTOR: Final = 0.9
#: Geschwindigkeitsobergrenze in px/s (≈ 1296 px/s).
#:
#: Warum: Die Kollisionserkennung ist diskret, sie sieht nur die Positionen am Ende jedes
#: Schritts. Fliegen zwei der kleinsten Körper frontal aufeinander zu, darf ihre
#: Relativbewegung pro Schritt (2·v·dt) die Summe ihrer Radien (2·MIN_BODY_SIZE) nicht
#: überschreiten – sonst springen die Mittelpunkte aneinander vorbei ("Tunneln").
#: Daraus folgt v < MIN_BODY_SIZE / dt. Die Wände sind Halbebenen und damit ohnehin
#: tunnelsicher: Ein Körper "hinter" der Wand wird immer als Durchdringung erkannt.
MAX_SPEED: Final = TUNNELING_SAFETY_FACTOR * MIN_BODY_SIZE / TIME_STEP
#: Obergrenze der Winkelgeschwindigkeit in rad/s: So schnell dreht sich der kleinste Kreis,
#: wenn er mit MAX_SPEED rollt (ω = v / r). Verhindert numerisch explodierende Drehungen.
MAX_ANGULAR_SPEED: Final = MAX_SPEED / MIN_BODY_SIZE
#: Toleranz für Gleitkomma-Vergleiche, z. B. "Vektor hat Länge 0".
NUMERIC_EPSILON: Final = 1e-9
#: Umrechnung Sekunden → Millisekunden (pygame misst Frame-Zeiten in ms).
MILLISECONDS_PER_SECOND: Final = 1000.0
#: Standard-Materialwerte für neue Körper.
DEFAULT_DENSITY: Final = 1.0
DEFAULT_RESTITUTION: Final = 0.5
DEFAULT_FRICTION: Final = 0.5
@dataclass(frozen=True)
class PhysicsConfig:
"""Parameter der Physik-Engine.
Attributes:
gravity: Schwerkraftbeschleunigung (x, y) in px/s². 980 px/s² entspricht 9,8 m/s²
bei einem Maßstab von 100 px pro Meter.
time_step: Fester Zeitschritt in s.
velocity_iterations: Durchläufe des Impuls-Solvers pro Schritt. Mehr Durchläufe
lassen Stapel steifer werden, kosten aber Rechenzeit.
position_iterations: Durchläufe der Positionskorrektur pro Schritt.
restitution_threshold: Minimale Aufprallgeschwindigkeit in px/s, ab der Körper
abprallen. Darunter wird der Stoß als unelastisch behandelt, damit aufliegende
Körper nicht durch winzige Dauer-Hüpfer zittern.
penetration_slop: Geduldete Eindringtiefe in px. Wird nicht korrigiert, damit der
Kontakt zu einer Unterlage im nächsten Schritt sicher wieder erkannt wird –
sonst wechselten "Kontakt" und "kein Kontakt" ständig ab (Zittern).
position_correction_factor: Anteil der Eindringtiefe, der pro Durchlauf
korrigiert wird. Werte < 1 verteilen die Korrektur weich auf mehrere Schritte.
max_position_correction: Obergrenze der Korrektur pro Kontakt und Durchlauf in px.
Stark überlappende Körper (z. B. frisch erzeugt) werden so sanft getrennt.
max_speed: Geschwindigkeitsobergrenze in px/s (siehe MAX_SPEED).
max_angular_speed: Obergrenze der Winkelgeschwindigkeit in rad/s.
linear_damping: Lineare Dämpfung (Luftwiderstand) in 1/s.
angular_damping: Dämpfung der Drehung (Luftwiderstand) in 1/s.
rolling_resistance: Rollwiderstandsbeiwert μ_r (dimensionslos). An jedem Kontakt
bremst ein Moment von höchstens μ_r · Normalkraft · Radius die Drehung. Ohne
Rollwiderstand würde ein rollender Kreis nie anhalten, weil reines Rollen keine
Reibungsenergie verbraucht; ein Rechteck auf einem Kreis führe darauf wie auf
einer Rolle davon.
warm_starting: Startet den Solver mit den Impulsen des vorigen Schritts.
warm_start_min_alignment: Minimales Skalarprodukt zwischen alter und neuer
Kontaktnormale, damit der alte Impuls wiederverwendet wird. Dreht sich die
Normale stärker, passt der alte Impuls nicht mehr.
sleep_enabled: Schaltet den Schlafmodus für ruhende Körper ein.
sleep_linear_speed: Unterhalb dieser Geschwindigkeit (px/s) gilt ein Körper als
ruhend.
sleep_angular_speed: Unterhalb dieser Winkelgeschwindigkeit (rad/s) gilt ein Körper
als ruhend.
time_to_sleep: So lange (s) muss eine ganze Kontaktgruppe ruhen, bevor sie einschläft.
max_wall_penetration: Notbremse in px: Weiter darf ein Körper nie in eine Wand
ragen. Der Wert liegt über der größten Eindringtiefe, die ein frei fliegender
Körper in einem Schritt erreichen kann (MAX_SPEED · dt ≈ 10,8 px). Die Grenze
greift deshalb nie bei normalen Aufprallen – die löst der Solver –, sondern nur,
wenn ein Körper z. B. von einem gezogenen Objekt gegen die Wand gequetscht wird.
wall_restitution: Stoßzahl der Wände. 0 bedeutet: Beim Mischen mit max(e_A, e_B)
bestimmt allein das Material des Körpers, wie stark er abprallt.
wall_friction: Reibungskoeffizient der Wände.
"""
gravity: tuple[float, float] = (0.0, 980.0)
time_step: float = TIME_STEP
velocity_iterations: int = 8
position_iterations: int = 2
restitution_threshold: float = 50.0
penetration_slop: float = 0.5
position_correction_factor: float = 0.4
max_position_correction: float = 6.0
max_speed: float = MAX_SPEED
max_angular_speed: float = MAX_ANGULAR_SPEED
linear_damping: float = 0.0
angular_damping: float = 0.0
rolling_resistance: float = 0.1
warm_starting: bool = True
warm_start_min_alignment: float = 0.95
sleep_enabled: bool = True
sleep_linear_speed: float = 4.0
sleep_angular_speed: float = 0.1
time_to_sleep: float = 0.5
max_wall_penetration: float = MIN_BODY_SIZE
wall_restitution: float = 0.0
wall_friction: float = 0.6
#: Standardkonfiguration der Physik.
PHYSICS: Final = PhysicsConfig()
# =============================================================================
# Fenster und Hauptschleife
# =============================================================================
@dataclass(frozen=True)
class WindowConfig:
"""Fenster und Hauptschleife.
Attributes:
width: Fensterbreite in px.
height: Fensterhöhe in px.
title: Fenstertitel.
sidebar_width: Breite der Werkzeugleiste am linken Rand in px. Rechts davon
beginnt die Physikwelt.
fps_limit: Obergrenze der Bildrate. 60 Bilder/s genügen für flüssige Bewegung; die
Physik läuft unabhängig davon mit 120 Hz.
max_steps_per_frame: Höchstens so viele Physikschritte pro Bild (Schutz vor der
"Spiral of Death", siehe :class:`~sandbox.physics.timestep.FixedTimestep`).
stats_smoothing: Glättungsfaktor (0..1) der FPS-/Rechenzeit-Anzeige pro Bild.
screenshot_delay: Standard-Laufzeit (s) vor einem Bildschirmfoto mit
``--screenshot``.
"""
width: int = 1280
height: int = 720
title: str = "Physik-Sandbox"
sidebar_width: int = 260
fps_limit: int = 60
max_steps_per_frame: int = 8
stats_smoothing: float = 0.1
screenshot_delay: float = 3.0
# =============================================================================
# Werkzeuge und Interaktion
# =============================================================================
@dataclass(frozen=True)
class InteractionConfig:
"""Erzeugen, Ziehen und Werfen von Körpern.
Attributes:
drag_response_rate: Wie schnell ein gezogener Körper der Maus folgt (1/s). Pro
Physikschritt wird der Anteil ``rate·dt`` des Abstands zum Ziel überbrückt – bei
60/s und dt = 1/120 s also die Hälfte. Werte > 1/dt würden überschwingen.
throw_window: Zeitfenster (s) der Mausbewegung, aus dem die Wurfgeschwindigkeit
gemittelt wird ("die letzten Frames").
throw_smoothing_time: Zeitkonstante (s) der zusätzlichen exponentiellen Glättung.
max_throw_speed: Obergrenze der Wurfgeschwindigkeit in px/s.
pick_tolerance: Zusätzlicher Rand (px), mit dem kleine Körper leichter zu greifen
sind.
max_bodies: Höchstzahl gleichzeitiger Körper (schützt die Bildrate).
size_wheel_step: Größenänderung (px) pro Mausrad-Raster.
min_size: Kleinste wählbare Größe (Radius bzw. halbe Kantenlänge) in px.
max_size: Größte wählbare Größe in px.
size_step: Rasterung des Größenreglers in px.
min_aspect_ratio: Kleinstes Seitenverhältnis Breite:Höhe eines Rechtecks.
max_aspect_ratio: Größtes Seitenverhältnis Breite:Höhe eines Rechtecks.
aspect_ratio_step: Rasterung des Seitenverhältnis-Reglers.
min_density: Kleinste wählbare Dichte.
max_density: Größte wählbare Dichte.
density_step: Rasterung des Dichte-Reglers.
max_restitution: Größte wählbare Elastizität.
restitution_step: Rasterung des Elastizitäts-Reglers.
default_size: Startwert der Größe in px.
default_aspect_ratio: Startwert des Seitenverhältnisses.
default_density: Startwert der Dichte.
default_restitution: Startwert der Elastizität.
friction: Reibungskoeffizient neu erzeugter Körper.
"""
drag_response_rate: float = 60.0
throw_window: float = 0.1
throw_smoothing_time: float = 0.05
max_throw_speed: float = MAX_SPEED
pick_tolerance: float = 4.0
max_bodies: int = 300
size_wheel_step: float = 2.0
min_size: float = MIN_BODY_SIZE
max_size: float = MAX_BODY_SIZE
size_step: float = 1.0
min_aspect_ratio: float = 0.5
max_aspect_ratio: float = 2.0
aspect_ratio_step: float = 0.05
min_density: float = 0.2
max_density: float = 5.0
density_step: float = 0.1
max_restitution: float = 1.0
restitution_step: float = 0.05
default_size: float = 28.0
default_aspect_ratio: float = 1.5
default_density: float = DEFAULT_DENSITY
default_restitution: float = DEFAULT_RESTITUTION
friction: float = DEFAULT_FRICTION
#: Standardkonfiguration der Interaktion.
INTERACTION: Final = InteractionConfig()
# =============================================================================
# Darstellung
# =============================================================================
#: RGB-Farbe.
Color = tuple[int, int, int]
@dataclass(frozen=True)
class Theme:
"""Farben der Oberfläche (dunkles Farbschema).
Attributes:
world_background: Hintergrund der Physikwelt.
grid: Hilfsgitter der Welt.
world_border: Rahmen um die Welt.
sidebar_background: Hintergrund der Werkzeugleiste.
panel: Grundfarbe von Buttons und Reglern.
panel_hover: Button unter der Maus.
panel_active: Aktiver Button bzw. gewählte Option.
accent: Akzentfarbe (Reglerfüllung, aktive Umrandung).
text: Normaler Text.
text_dim: Nebensächlicher Text.
danger: Farbe für "Alles löschen".
highlight: Umrandung des Körpers unter der Maus.
held_outline: Umrandung des gezogenen Körpers.
throw_arrow: Pfeil der aktuellen Wurfgeschwindigkeit.
preview_alpha: Deckkraft (0–255) der Vorschau beim Erzeugen.
outline_shade: Faktor, um den die Umrandung dunkler ist als die Füllung.
debug_contact: Kontaktpunkte in der Debug-Ansicht.
debug_normal: Kontaktnormalen in der Debug-Ansicht.
debug_sleeping: Umrandung schlafender Körper in der Debug-Ansicht.
body_palette: Auswahl an Körperfarben.
"""
world_background: Color = (22, 25, 32)
grid: Color = (32, 36, 46)
world_border: Color = (64, 71, 88)
sidebar_background: Color = (15, 17, 22)
panel: Color = (31, 35, 45)
panel_hover: Color = (42, 47, 60)
panel_active: Color = (48, 72, 102)
accent: Color = (96, 176, 240)
text: Color = (226, 230, 239)
text_dim: Color = (136, 145, 164)
danger: Color = (228, 92, 92)
highlight: Color = (255, 255, 255)
held_outline: Color = (255, 214, 102)
throw_arrow: Color = (255, 214, 102)
preview_alpha: int = 110
outline_shade: float = 0.6
debug_contact: Color = (255, 214, 10)
debug_normal: Color = (255, 110, 64)
debug_sleeping: Color = (120, 130, 150)
body_palette: tuple[Color, ...] = (
(239, 83, 80),
(255, 167, 38),
(255, 213, 79),
(102, 187, 106),
(38, 198, 218),
(66, 133, 244),
(171, 71, 188),
(236, 64, 122),
)
@dataclass(frozen=True)
class UiConfig:
"""Schriften, Abstände und Maße der Oberfläche.
Attributes:
font_names: Bevorzugte Systemschriften (die erste vorhandene wird genutzt).
title_font_size: Schriftgröße des Titels.
label_font_size: Schriftgröße von Beschriftungen.
small_font_size: Schriftgröße von Hinweisen und Werten.
padding: Innenabstand der Sidebar in px.
section_gap: Abstand zwischen Abschnitten in px.
row_gap: Abstand zwischen Elementen eines Abschnitts in px.
header_height: Höhe einer Abschnittsüberschrift in px.
button_height: Höhe von Buttons in px.
slider_height: Höhe eines Reglers (Beschriftung + Schiene) in px.
slider_track_height: Dicke der Reglerschiene in px.
slider_knob_radius: Radius des Reglerknopfs in px.
swatch_size: Kantenlänge eines Farbfelds in px.
swatch_gap: Abstand zwischen Farbfeldern in px.
spawn_tile_height: Höhe der Vorschau-Kachel in px.
corner_radius: Eckenradius von Buttons und Kacheln in px.
border_width: Linienstärke von Rahmen (Buttons, Umschalter, Weltrand) in px.
selection_ring_gap: Abstand des Auswahlrings um das gewählte Farbfeld in px.
selection_ring_width: Linienstärke des Auswahlrings in px.
grid_spacing: Abstand der Gitterlinien der Welt in px.
outline_width: Linienstärke von Körperumrandungen in px.
highlight_width: Linienstärke der Hervorhebung in px.
throw_arrow_time: Länge des Wurfpfeils als Flugzeit in s (Pfeil = v · Zeit).
debug_normal_length: Länge der gezeichneten Kontaktnormalen in px.
debug_contact_radius: Radius der gezeichneten Kontaktpunkte in px.
preview_scale_limit: Höchster Anteil der Kachelhöhe, den die Vorschau einnimmt.
marker_length: Länge des Drehmarkers eines Kreises als Anteil des Radius.
text_cache_size: So viele gerenderte Texte werden höchstens zwischengespeichert.
"""
font_names: tuple[str, ...] = ("segoeui", "helveticaneue", "dejavusans", "arial")
title_font_size: int = 22
label_font_size: int = 15
small_font_size: int = 13
padding: int = 16
section_gap: int = 12
row_gap: int = 6
header_height: int = 20
button_height: int = 32
slider_height: int = 38
slider_track_height: int = 4
slider_knob_radius: int = 7
swatch_size: int = 22
swatch_gap: int = 3
spawn_tile_height: int = 92
corner_radius: int = 6
border_width: int = 1
selection_ring_gap: int = 2
selection_ring_width: int = 2
grid_spacing: int = 40
outline_width: int = 2
highlight_width: int = 3
throw_arrow_time: float = 0.15
debug_normal_length: float = 18.0
debug_contact_radius: int = 3
preview_scale_limit: float = 0.8
marker_length: float = 0.85
text_cache_size: int = 512
#: Tastenbelegung: Tastenname (wie in ``pygame.key.key_code``) → Aktion.
KEY_BINDINGS: Final = (
("space", "toggle_pause"),
("g", "toggle_gravity"),
("c", "clear"),
("delete", "delete_hovered"),
("1", "shape_circle"),
("2", "shape_box"),
("f1", "toggle_debug"),
("escape", "quit"),
)
@dataclass(frozen=True)
class DemoSceneConfig:
"""Aufbau der Beispielszene (Start mit ``--demo``).
x- und y-Positionen sind Anteile der Weltbreite bzw. -höhe, damit die Szene in jede
Fenstergröße passt.
Attributes:
tower_x: Position des Rechteckturms.
tower_levels: Anzahl der Rechtecke im Turm.
box_size: Größe der Turm-Rechtecke in px.
box_aspect_ratio: Seitenverhältnis der Turm-Rechtecke.
pyramid_x: Mitte der Kreispyramide.
pyramid_rows: Anzahl der Reihen der Pyramide.
pyramid_circle_size: Radius der Pyramidenkreise in px.
pyramid_density: Dichte der Pyramidenkreise (schwer, damit sie stabil liegen).
pyramid_row_height: Vertikaler Reihenabstand in Radien. Dichteste Packung wäre
√3 ≈ 1,732; etwas mehr lässt einen hauchdünnen Spalt, damit nichts überlappt.
balls_x: Position des ersten Gummiballs.
balls_y: Starthöhe der Gummibälle.
ball_count: Anzahl der Gummibälle.
ball_size: Radius der Gummibälle in px.
ball_spacing: Abstand der Bälle in Radien.
ball_restitution: Elastizität der Gummibälle.
"""
tower_x: float = 0.78
tower_levels: int = 7
box_size: float = 22.0
box_aspect_ratio: float = 2.0
pyramid_x: float = 0.45
pyramid_rows: int = 5
pyramid_circle_size: float = 20.0
pyramid_density: float = 3.0
pyramid_row_height: float = 1.75
balls_x: float = 0.15
balls_y: float = 0.2
ball_count: int = 4
ball_size: float = 16.0
ball_spacing: float = 2.4
ball_restitution: float = 0.8
@dataclass(frozen=True)
class AppConfig:
"""Gesamtkonfiguration der Anwendung."""
window: WindowConfig = WindowConfig()
physics: PhysicsConfig = PhysicsConfig()
interaction: InteractionConfig = InteractionConfig()
theme: Theme = Theme()
ui: UiConfig = UiConfig()
demo: DemoSceneConfig = DemoSceneConfig()
#: Standardkonfiguration der Anwendung.
APP: Final = AppConfig()
+51
View File
@@ -0,0 +1,51 @@
"""Beispielszene zum Ausprobieren (Start mit ``python -m sandbox --demo``)."""
from __future__ import annotations
from dataclasses import replace
from sandbox.config import DemoSceneConfig
from sandbox.interaction import BodyFactory, ShapeKind, ToolSettings
from sandbox.physics import Vector2
def build_demo_scene(factory: BodyFactory, base: ToolSettings, scene: DemoSceneConfig) -> None:
"""Baut einen Turm aus Rechtecken, eine Kreispyramide und ein paar Gummibälle auf.
Args:
factory: Erzeugt die Körper (und fügt sie der Welt hinzu).
base: Ausgangseinstellungen (Farbe, Material), die je Gruppe abgewandelt werden.
scene: Aufbau der Szene.
"""
world = factory.world
floor = world.height
boxes = replace(
base, shape=ShapeKind.BOX, size=scene.box_size, aspect_ratio=scene.box_aspect_ratio
)
_, half_height = boxes.half_extents()
for level in range(scene.tower_levels):
y = floor - half_height - level * 2 * half_height
factory.create(boxes, Vector2(world.width * scene.tower_x, y))
circles = replace(
base,
shape=ShapeKind.CIRCLE,
size=scene.pyramid_circle_size,
density=scene.pyramid_density,
)
radius = circles.size
rows = scene.pyramid_rows
for row in range(rows):
for column in range(rows - row):
# Jede Reihe ist um einen Radius eingerückt und liegt in den Mulden der unteren.
x = world.width * scene.pyramid_x + (2 * column + row - rows + 1) * radius
y = floor - radius - row * radius * scene.pyramid_row_height
factory.create(circles, Vector2(x, y))
balls = replace(
base, shape=ShapeKind.CIRCLE, size=scene.ball_size, restitution=scene.ball_restitution
)
for index in range(scene.ball_count):
x = world.width * scene.balls_x + index * scene.ball_spacing * scene.ball_size
factory.create(balls, Vector2(x, world.height * scene.balls_y))
+232
View File
@@ -0,0 +1,232 @@
"""Übersetzt pygame-Eingaben in Aktionen von Sidebar, Welt und Werkzeugen.
Maus-Events werden zuerst der Sidebar angeboten. Was sie nicht verbraucht und in der Welt
liegt, wird zur Werkzeug-Aktion: Linksklick auf einen Körper greift ihn, Linksklick ins
Leere erzeugt einen neuen Körper (und greift ihn sofort, sodass man ihn direkt werfen
kann), Rechtsklick löscht.
"""
from __future__ import annotations
import contextlib
from collections.abc import Callable
from functools import partial
from typing import Protocol
import pygame
from sandbox.config import KEY_BINDINGS, InteractionConfig
from sandbox.interaction import BodyFactory, DragController, ShapeKind, ToolSettings
from sandbox.physics import Body, Vector2, World
from sandbox.renderer import SpawnPreview
from sandbox.ui.sidebar import Sidebar, SidebarControls
from sandbox.viewport import Viewport
class AppControls(SidebarControls, Protocol):
"""Aktionen der Anwendung, die per Tastatur ausgelöst werden können."""
def request_quit(self) -> None:
"""Beendet die Anwendung nach dem aktuellen Frame."""
...
def toggle_debug(self) -> None:
"""Schaltet die Debug-Ansicht um."""
...
class InputHandler:
"""Verteilt Maus- und Tastatur-Events.
Args:
world: Die Physikwelt.
viewport: Lage der Welt auf dem Bildschirm.
drag: Steuerung für Greifen und Werfen.
factory: Erzeugt neue Körper.
tools: Aktuelle Werkzeug-Einstellungen.
config: Interaktionsparameter.
controls: Aktionen der Anwendung.
"""
def __init__(
self,
*,
world: World,
viewport: Viewport,
drag: DragController,
factory: BodyFactory,
tools: ToolSettings,
config: InteractionConfig,
controls: AppControls,
) -> None:
self.world = world
self.viewport = viewport
self.drag = drag
self.factory = factory
self.tools = tools
self.config = config
self.controls = controls
self.sidebar: Sidebar | None = None
self.mouse_pos: tuple[int, int] = pygame.mouse.get_pos()
self.hovered_body: Body | None = None
self.spawn_drag_active = False
self._cursor: int | None = None
actions: dict[str, Callable[[], None]] = {
"toggle_pause": controls.toggle_pause,
"toggle_gravity": controls.toggle_gravity,
"clear": controls.clear_world,
"delete_hovered": self.delete_hovered,
"shape_circle": partial(setattr, tools, "shape", ShapeKind.CIRCLE),
"shape_box": partial(setattr, tools, "shape", ShapeKind.BOX),
"toggle_debug": controls.toggle_debug,
"quit": controls.request_quit,
}
self._key_actions = {
pygame.key.key_code(key): actions[action] for key, action in KEY_BINDINGS
}
# ------------------------------------------------------------------ Abfragen
@property
def pointer_in_world(self) -> bool:
"""Ob die Maus über der Welt steht."""
return self.viewport.contains(self.mouse_pos)
@property
def world_pointer(self) -> Vector2:
"""Mausposition in Weltkoordinaten."""
return self.viewport.to_world(self.mouse_pos)
def spawn_preview(self) -> SpawnPreview | None:
"""Vorschau des Objekts, das ein Klick an der aktuellen Mausposition erzeugen würde."""
half_extents = self.tools.half_extents()
if self.spawn_drag_active:
center = (float(self.mouse_pos[0]), float(self.mouse_pos[1]))
elif (
self.pointer_in_world
and self.drag.body is None
and self.hovered_body is None
and not self.factory.is_full
):
clamped = self.world.clamp_inside(self.world_pointer, half_extents)
center = self.viewport.to_screen(clamped)
else:
return None
color = self.factory.color_for(self.tools)
return SpawnPreview(self.tools.shape, half_extents, color, center)
# ------------------------------------------------------------------ Aktionen
def begin_spawn_drag(self) -> None:
"""Beginnt das Herausziehen eines neuen Objekts aus der Sidebar."""
self.spawn_drag_active = True
def delete_hovered(self) -> None:
"""Löscht den Körper unter der Maus."""
body = self._body_under_pointer()
if body is not None:
self.delete(body)
def delete(self, body: Body) -> None:
"""Entfernt einen Körper (und lässt ihn vorher los, falls er gehalten wird)."""
if self.drag.body is body:
self.drag.cancel()
if self.hovered_body is body:
self.hovered_body = None
self.world.remove_body(body)
def forget_bodies(self) -> None:
"""Vergisst Verweise auf Körper (nach "Alles löschen")."""
self.drag.cancel()
self.hovered_body = None
self.spawn_drag_active = False
# ------------------------------------------------------------------ Events
def handle_event(self, event: pygame.event.Event, time: float) -> None:
"""Verarbeitet ein pygame-Event.
Args:
event: Das Event.
time: Aktuelle Anwendungszeit in s (für die Wurfgeschwindigkeit).
"""
if event.type == pygame.QUIT:
self.controls.request_quit()
elif event.type == pygame.KEYDOWN:
action = self._key_actions.get(event.key)
if action is not None:
action()
elif event.type == pygame.MOUSEBUTTONDOWN:
self.mouse_pos = event.pos
if not self._offer_to_sidebar(event):
self._press_in_world(event.button, time)
elif event.type == pygame.MOUSEMOTION:
self.mouse_pos = event.pos
if not self._offer_to_sidebar(event):
self._move(time)
elif event.type == pygame.MOUSEBUTTONUP:
self.mouse_pos = event.pos
if event.button == pygame.BUTTON_LEFT:
# Loslassen beendet ein Ziehen immer – auch über der Sidebar.
self.drag.release(paused=self.controls.is_paused())
self.spawn_drag_active = False
self._offer_to_sidebar(event)
elif event.type == pygame.MOUSEWHEEL and not self._offer_to_sidebar(event):
self._change_size(event.y)
def update(self, time: float) -> None:
"""Einmal pro Frame: Mausposition an den Wurf-Tracker melden, Hover bestimmen.
Die Position wird auch ohne Maus-Event gemeldet: Steht die Maus still, kommen keine
Events – der Tracker muss aber merken, dass die Geschwindigkeit auf 0 fällt.
"""
if self.drag.body is not None:
self.drag.move(self.world_pointer, time)
self.hovered_body = None if self.drag.body is not None else self._body_under_pointer()
grabbing = self.drag.body is not None or self.hovered_body is not None
cursor = pygame.SYSTEM_CURSOR_HAND if grabbing else pygame.SYSTEM_CURSOR_ARROW
if cursor != self._cursor:
self._cursor = cursor
# Im Headless-Modus (Tests) gibt es keinen echten Mauszeiger.
with contextlib.suppress(pygame.error):
pygame.mouse.set_cursor(cursor)
def _offer_to_sidebar(self, event: pygame.event.Event) -> bool:
"""Bietet ein Event der Sidebar an; True, wenn sie es verbraucht hat."""
if self.sidebar is None:
return False
return self.sidebar.handle_event(event, self.mouse_pos)
def _press_in_world(self, button: int, time: float) -> None:
if not self.pointer_in_world:
return
pointer = self.world_pointer
if button == pygame.BUTTON_LEFT:
body = self._body_under_pointer() or self.factory.create(self.tools, pointer)
if body is not None:
self.drag.grab(body, pointer, time)
elif button == pygame.BUTTON_RIGHT:
body = self._body_under_pointer()
if body is not None:
self.delete(body)
def _move(self, time: float) -> None:
if self.spawn_drag_active and self.pointer_in_world:
# Aus der Sidebar gezogenes Objekt erscheint, sobald die Maus die Welt erreicht,
# und hängt ab dann wie ein gegriffenes Objekt an der Maus.
self.spawn_drag_active = False
body = self.factory.create(self.tools, self.world_pointer)
if body is not None:
self.drag.grab(body, self.world_pointer, time)
if self.drag.body is not None:
self.drag.move(self.world_pointer, time)
def _change_size(self, wheel_steps: int) -> None:
config = self.config
size = self.tools.size + wheel_steps * config.size_wheel_step
self.tools.size = min(max(size, config.min_size), config.max_size)
def _body_under_pointer(self) -> Body | None:
if not self.pointer_in_world:
return None
return self.world.body_at(self.world_pointer, self.config.pick_tolerance)
+20
View File
@@ -0,0 +1,20 @@
"""Werkzeug-Logik der Sandbox: Körper erzeugen, greifen, ziehen und werfen.
Die Klassen hier arbeiten ausschließlich mit Weltkoordinaten und Zeitstempeln, nicht mit
pygame-Events. Dadurch lässt sich das Verhalten (z. B. die Glättung der
Wurfgeschwindigkeit) ohne Fenster testen; die Übersetzung von Maus-Events übernimmt
:mod:`sandbox.input_handler`.
"""
from sandbox.interaction.drag import DragController
from sandbox.interaction.tools import BodyFactory, BodyStyle, ShapeKind, ToolSettings
from sandbox.interaction.velocity_tracker import VelocityTracker
__all__ = [
"BodyFactory",
"BodyStyle",
"DragController",
"ShapeKind",
"ToolSettings",
"VelocityTracker",
]
+116
View File
@@ -0,0 +1,116 @@
"""Greifen, Ziehen und Werfen eines Körpers mit der Maus."""
from __future__ import annotations
from sandbox.config import InteractionConfig
from sandbox.interaction.velocity_tracker import VelocityTracker
from sandbox.physics import ZERO, Body, Vector2, World
class DragController:
"""Führt einen gegriffenen Körper zur Maus, ohne die Physik auszuhebeln.
Der Körper wird *nicht* einfach an die Mausposition gesetzt – dann könnte er durch
andere Körper und Wände hindurchgeschoben werden. Stattdessen bekommt er vor jedem
Physikschritt die Geschwindigkeit, die ihn in Richtung Ziel bringt::
v = (Ziel − Position) · rate
Danach löst der Solver ganz normal die Kontakte: Leichte Körper werden weggeschoben,
Wände und schwere Stapel bremsen den gezogenen Körper. Das gezogene Objekt kollidiert
also weiterhin. Beim Loslassen erhält es die geglättete Mausgeschwindigkeit
(:class:`~sandbox.interaction.velocity_tracker.VelocityTracker`) – das ist der Wurf.
Args:
world: Die Welt, in der gezogen wird.
config: Folgegeschwindigkeit, Wurf-Glättung und -Obergrenze.
"""
def __init__(self, world: World, config: InteractionConfig) -> None:
self.world = world
self.config = config
self.tracker = VelocityTracker(config.throw_window, config.throw_smoothing_time)
self._body: Body | None = None
self._offset = ZERO
self._target = ZERO
@property
def body(self) -> Body | None:
"""Der aktuell gehaltene Körper (oder ``None``)."""
return self._body
@property
def target(self) -> Vector2:
"""Zielposition des Schwerpunkts (Mausposition plus Griffversatz)."""
return self._target
@property
def throw_velocity(self) -> Vector2:
"""Geschwindigkeit, die der Körper beim Loslassen erhielte (px/s)."""
return self.tracker.velocity.clamped(self.config.max_throw_speed)
def grab(self, body: Body, pointer: Vector2, time: float) -> None:
"""Greift einen Körper an der Stelle ``pointer``.
Der Versatz zwischen Griffpunkt und Schwerpunkt bleibt beim Ziehen erhalten, damit
der Körper nicht mit seinem Mittelpunkt zur Maus springt.
"""
self.cancel()
self._body = body
body.held = True
body.wake()
self._offset = body.position - pointer
self._target = body.position
self.tracker.reset(time, pointer)
def move(self, pointer: Vector2, time: float) -> None:
"""Meldet die aktuelle Mausposition (bei jedem Event und einmal pro Frame)."""
body = self._body
if body is None:
return
self.tracker.add_sample(time, pointer)
# Das Ziel so klemmen, dass der Körper ganz in die Welt passt; sonst würde er
# dauerhaft gegen die Wand gedrückt.
self._target = self.world.clamp_inside(pointer + self._offset, body.half_extents())
def apply(self, dt: float) -> None:
"""Setzt vor einem Physikschritt die Geschwindigkeit Richtung Ziel."""
body = self._body
if body is None:
return
# rate·dt ≤ 1, sonst würde der Körper über das Ziel hinausschießen und pendeln.
rate = min(self.config.drag_response_rate, 1.0 / dt)
velocity = (self._target - body.position) * rate
body.velocity = velocity.clamped(self.world.config.max_speed)
body.wake()
def place(self) -> None:
"""Setzt den Körper direkt aufs Ziel – nur im Pausenmodus, wenn keine Physik läuft."""
body = self._body
if body is None:
return
body.teleport(self._target)
body.velocity = ZERO
def release(self, *, paused: bool) -> Body | None:
"""Lässt den Körper los und wirft ihn mit der geglätteten Mausgeschwindigkeit.
Args:
paused: Im Pausenmodus wird nicht geworfen; der Körper bleibt liegen.
Returns:
Den losgelassenen Körper (oder ``None``, wenn keiner gehalten wurde).
"""
body = self._body
if body is None:
return None
body.velocity = ZERO if paused else self.throw_velocity
self.cancel()
return body
def cancel(self) -> None:
"""Beendet das Halten, ohne die Geschwindigkeit zu ändern (z. B. beim Löschen)."""
if self._body is not None:
self._body.held = False
self._body.wake()
self._body = None
+137
View File
@@ -0,0 +1,137 @@
"""Werkzeug-Einstellungen der Sidebar und das Erzeugen neuer Körper."""
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from enum import Enum
from sandbox.config import MIN_BODY_SIZE, Color, InteractionConfig
from sandbox.physics import Body, Material, Vector2, World
class ShapeKind(Enum):
"""Form neu erzeugter Körper."""
CIRCLE = "circle"
BOX = "box"
@dataclass
class ToolSettings:
"""Aktuelle Auswahl in der Werkzeugleiste.
Attributes:
shape: Form neuer Körper.
size: Radius bzw. "mittlere" halbe Kantenlänge in px.
aspect_ratio: Seitenverhältnis Breite:Höhe (nur für Rechtecke).
density: Dichte (Masse = Fläche × Dichte).
restitution: Elastizität (Stoßzahl).
color_index: Index in die Farbpalette oder ``None`` für eine Zufallsfarbe.
"""
shape: ShapeKind
size: float
aspect_ratio: float
density: float
restitution: float
color_index: int | None = None
@classmethod
def from_config(cls, config: InteractionConfig) -> ToolSettings:
"""Startwerte aus der Konfiguration."""
return cls(
shape=ShapeKind.CIRCLE,
size=config.default_size,
aspect_ratio=config.default_aspect_ratio,
density=config.default_density,
restitution=config.default_restitution,
)
def half_extents(self) -> tuple[float, float]:
"""Halbe Breite und Höhe eines neuen Körpers.
Für Rechtecke wird die Größe mit √(Seitenverhältnis) gestreckt bzw. gestaucht. So
bleibt die Fläche – und damit die Masse – beim Ändern des Seitenverhältnisses gleich,
nur die Form ändert sich. Ausnahme: Keine halbe Kantenlänge darf unter
MIN_BODY_SIZE fallen, weil die Geschwindigkeitsgrenze gegen Tunneln auf dieser
Mindestgröße beruht. Bei sehr kleinen Rechtecken wird die kurze Seite deshalb
angehoben, und die Fläche wächst etwas.
"""
if self.shape is ShapeKind.CIRCLE:
return (self.size, self.size)
stretch = math.sqrt(self.aspect_ratio)
return (max(self.size * stretch, MIN_BODY_SIZE), max(self.size / stretch, MIN_BODY_SIZE))
@dataclass(frozen=True)
class BodyStyle:
"""Darstellungsdaten eines Körpers (liegen in ``Body.user_data``).
Attributes:
color: Füllfarbe.
"""
color: Color
class BodyFactory:
"""Erzeugt Körper gemäß den Werkzeug-Einstellungen und fügt sie der Welt hinzu.
Args:
world: Zielwelt.
config: Grenzen und Materialwerte.
palette: Verfügbare Farben.
rng: Zufallsgenerator für Zufallsfarben (injizierbar für reproduzierbare Tests).
"""
def __init__(
self,
world: World,
config: InteractionConfig,
palette: tuple[Color, ...],
rng: random.Random | None = None,
) -> None:
self.world = world
self.config = config
self.palette = palette
self._rng = rng or random.Random()
# Die nächste Zufallsfarbe wird im Voraus gewürfelt, damit die Vorschau-Kachel
# genau die Farbe zeigt, die das nächste Objekt bekommt.
self._next_random_color = self._rng.choice(palette)
@property
def is_full(self) -> bool:
"""True, wenn die Höchstzahl an Körpern erreicht ist."""
return len(self.world.bodies) >= self.config.max_bodies
def color_for(self, settings: ToolSettings) -> Color:
"""Farbe, die das nächste Objekt mit diesen Einstellungen bekommt."""
if settings.color_index is None:
return self._next_random_color
return self.palette[settings.color_index]
def create(self, settings: ToolSettings, position: Vector2) -> Body | None:
"""Erzeugt einen Körper an ``position`` (wird bei Bedarf in die Welt geschoben).
Returns:
Den neuen Körper oder ``None``, wenn die Höchstzahl erreicht ist.
"""
if self.is_full:
return None
half_width, half_height = settings.half_extents()
center = self.world.clamp_inside(position, (half_width, half_height))
material = Material(
density=settings.density,
restitution=settings.restitution,
friction=self.config.friction,
)
style = BodyStyle(self.color_for(settings))
if settings.color_index is None:
self._next_random_color = self._rng.choice(self.palette)
if settings.shape is ShapeKind.CIRCLE:
body = Body.circle(center, half_width, material, user_data=style)
else:
body = Body.box(center, half_width, half_height, material, user_data=style)
return self.world.add_body(body)
@@ -0,0 +1,79 @@
"""Geglättete Zeigergeschwindigkeit für das Werfen."""
from __future__ import annotations
import math
from collections import deque
from sandbox.physics import ZERO, Vector2
class VelocityTracker:
"""Schätzt, wie schnell sich die Maus zuletzt bewegt hat.
Die Geschwindigkeit zwischen nur zwei aufeinanderfolgenden Frames ist sehr unruhig:
Maus-Events kommen unregelmäßig, und ein einzelner kurzer Frame liefert einen riesigen
Wert. Deshalb zwei Stufen:
1. **Zeitfenster:** Mittlere Geschwindigkeit über die letzten ``window`` Sekunden
(Weg / Zeit zwischen ältester und neuester Probe) – das entspricht "der Bewegung der
letzten Frames" und ist unabhängig von der Bildrate.
2. **Exponentielle Glättung:** ``v ← v + (v_roh − v)·(1 − e^(−Δt/τ))``. Der
Exponentialterm macht die Glättung ebenfalls bildratenunabhängig: Zwei Frames à
8 ms glätten genauso stark wie ein Frame à 16 ms.
Hält man die Maus vor dem Loslassen still, sinkt die Geschwindigkeit schnell gegen 0 –
das Objekt fällt dann einfach herunter, statt mit einem veralteten Schwung davonzufliegen.
Args:
window: Länge des Mittelungsfensters in s.
smoothing_time: Zeitkonstante τ der exponentiellen Glättung in s.
"""
def __init__(self, window: float, smoothing_time: float) -> None:
if not (window > 0.0 and smoothing_time > 0.0):
raise ValueError("Fenster und Glättungszeit müssen positiv sein")
self.window = window
self.smoothing_time = smoothing_time
self._samples: deque[tuple[float, Vector2]] = deque()
self._velocity = ZERO
@property
def velocity(self) -> Vector2:
"""Aktuelle geglättete Geschwindigkeit in px/s."""
return self._velocity
def reset(self, time: float, position: Vector2) -> None:
"""Beginnt eine neue Messung (z. B. beim Greifen eines Körpers)."""
self._samples.clear()
self._samples.append((time, position))
self._velocity = ZERO
def add_sample(self, time: float, position: Vector2) -> None:
"""Nimmt eine neue Zeigerposition auf.
Args:
time: Zeitstempel in s (monoton steigend).
position: Zeigerposition in Weltkoordinaten.
"""
samples = self._samples
if not samples:
self.reset(time, position)
return
last_time = samples[-1][0]
elapsed = time - last_time
if elapsed <= 0.0:
# Mehrere Events im selben Frame: nur die neueste Position zählt.
samples[-1] = (last_time, position)
return
samples.append((time, position))
# Proben vor dem Fenster verwerfen – die jüngste davon bleibt als Bezugspunkt,
# damit das Fenster auch bei langen Frames nie leer ist. (Die neueste Probe liegt
# immer im Fenster, es bleiben also stets mindestens zwei Proben übrig.)
while len(samples) > 1 and samples[1][0] <= time - self.window:
samples.popleft()
oldest_time, oldest_position = samples[0]
raw = (position - oldest_position) / (time - oldest_time)
blend = 1.0 - math.exp(-elapsed / self.smoothing_time)
self._velocity = self._velocity + (raw - self._velocity) * blend
+283
View File
@@ -0,0 +1,283 @@
"""Einstiegspunkt und Hauptschleife der Physik-Sandbox.
Ablauf eines Frames (:meth:`App.frame`):
1. Events verarbeiten (Sidebar, Werkzeuge, Tastatur).
2. Die vergangene Zeit an den festen Zeitschritt geben; er liefert die Zahl fälliger
Physikschritte (bei 60 Bildern/s meist zwei Schritte à 1/120 s).
3. Vor jedem Physikschritt die Geschwindigkeit eines gezogenen Körpers setzen, dann
``World.step`` ausführen.
4. Zeichnen – mit Interpolation zwischen den letzten beiden Physikzuständen.
"""
from __future__ import annotations
import argparse
import random
import time
from pathlib import Path
import pygame
from sandbox.config import APP, MILLISECONDS_PER_SECOND, AppConfig, Color
from sandbox.demo import build_demo_scene
from sandbox.input_handler import InputHandler
from sandbox.interaction import BodyFactory, DragController, ToolSettings
from sandbox.physics import FixedTimestep, World
from sandbox.renderer import Renderer, WorldOverlay
from sandbox.ui.sidebar import Sidebar
from sandbox.ui.style import UiStyle
from sandbox.viewport import Viewport
class FrameStats:
"""Geglättete Bildrate und Physik-Rechenzeit für die Anzeige.
Args:
smoothing: Anteil (0..1), mit dem ein neuer Messwert pro Frame einfließt.
"""
def __init__(self, smoothing: float) -> None:
self.smoothing = smoothing
self.fps = 0.0
self.physics_ms = 0.0
def update(self, frame_time: float, physics_seconds: float) -> None:
"""Nimmt die Messwerte eines Frames auf."""
if frame_time > 0.0:
fps = 1.0 / frame_time
self.fps = fps if self.fps == 0.0 else self.fps + (fps - self.fps) * self.smoothing
physics_ms = physics_seconds * MILLISECONDS_PER_SECOND
self.physics_ms += (physics_ms - self.physics_ms) * self.smoothing
class App:
"""Die Anwendung: Fenster, Welt, Werkzeuge, Sidebar und Hauptschleife.
Args:
config: Gesamtkonfiguration.
rng: Zufallsgenerator für Zufallsfarben (für reproduzierbare Tests).
"""
def __init__(self, config: AppConfig = APP, *, rng: random.Random | None = None) -> None:
pygame.init()
self.config = config
window = config.window
self.screen = pygame.display.set_mode((window.width, window.height))
pygame.display.set_caption(window.title)
self.clock = pygame.time.Clock()
self.viewport = Viewport(
window.sidebar_width, 0, window.width - window.sidebar_width, window.height
)
self.world = World(self.viewport.width, self.viewport.height, config.physics)
self.timestep = FixedTimestep(config.physics.time_step, window.max_steps_per_frame)
self.tools = ToolSettings.from_config(config.interaction)
self.factory = BodyFactory(
self.world, config.interaction, config.theme.body_palette, rng or random.Random()
)
self.drag = DragController(self.world, config.interaction)
self.style = UiStyle(config.theme, config.ui)
self.input = InputHandler(
world=self.world,
viewport=self.viewport,
drag=self.drag,
factory=self.factory,
tools=self.tools,
config=config.interaction,
controls=self,
)
self.sidebar = Sidebar(
pygame.Rect(0, 0, window.sidebar_width, window.height),
self.style,
self.tools,
config.interaction,
config.theme.body_palette,
self,
)
self.input.sidebar = self.sidebar
self.renderer = Renderer(self.screen, self.viewport, self.style)
self.stats = FrameStats(window.stats_smoothing)
self.paused = False
self.debug = False
self.running = True
self.time = 0.0
# ------------------------------------------------------------------ Steuerung
def is_paused(self) -> bool:
"""Ob die Simulation pausiert ist."""
return self.paused
def toggle_pause(self) -> None:
"""Pausiert bzw. setzt die Simulation fort."""
self.paused = not self.paused
# Angesammelte Zeit verwerfen, sonst holte die Physik nach der Pause Schritte nach.
self.timestep.reset()
def gravity_enabled(self) -> bool:
"""Ob die Schwerkraft wirkt."""
return self.world.gravity_enabled
def toggle_gravity(self) -> None:
"""Schaltet die Schwerkraft an bzw. aus."""
self.world.gravity_enabled = not self.world.gravity_enabled
def clear_world(self) -> None:
"""Entfernt alle Körper."""
self.input.forget_bodies()
self.world.clear()
def begin_spawn_drag(self) -> None:
"""Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel."""
self.input.begin_spawn_drag()
def preview_color(self) -> Color:
"""Farbe des nächsten Objekts."""
return self.factory.color_for(self.tools)
def status_lines(self) -> list[str]:
"""Statuszeilen am unteren Rand der Sidebar."""
limit = self.config.interaction.max_bodies
count = len(self.world.bodies)
full = " (voll)" if count >= limit else ""
return [
f"Objekte: {count} / {limit}{full}",
f"{self.stats.fps:.0f} FPS · Physik {self.stats.physics_ms:.1f} ms",
"Rechtsklick: löschen · Rad: Größe",
"F1: Debug · Esc: Beenden",
]
def request_quit(self) -> None:
"""Beendet die Hauptschleife nach dem aktuellen Frame."""
self.running = False
def toggle_debug(self) -> None:
"""Schaltet die Debug-Ansicht (Kontakte, Schlafzustand) um."""
self.debug = not self.debug
def load_demo_scene(self) -> None:
"""Ersetzt den Inhalt der Welt durch die Beispielszene."""
self.clear_world()
build_demo_scene(self.factory, self.tools, self.config.demo)
# ------------------------------------------------------------------ Hauptschleife
def frame(self, frame_time: float) -> None:
"""Verarbeitet Eingaben, rechnet die Physik und zeichnet ein Bild.
Args:
frame_time: Real vergangene Zeit seit dem letzten Frame in s.
"""
self.time += frame_time
for event in pygame.event.get():
self.input.handle_event(event, self.time)
self.input.update(self.time)
physics_start = time.perf_counter()
if self.paused:
self.drag.place() # Im Pausenmodus wird direkt verschoben, ohne Physik.
else:
dt = self.timestep.step
for _ in range(self.timestep.advance(frame_time)):
self.drag.apply(dt)
self.world.step(dt)
self.stats.update(frame_time, time.perf_counter() - physics_start)
self.draw()
pygame.display.flip()
def draw(self) -> None:
"""Zeichnet Welt, Sidebar und – obenauf – die Vorschau eines neuen Objekts."""
overlay = WorldOverlay(
hovered=self.input.hovered_body,
held=self.drag.body,
throw_velocity=self.drag.throw_velocity,
paused=self.paused,
debug=self.debug,
debug_lines=self._debug_lines() if self.debug else [],
)
alpha = 1.0 if self.paused else self.timestep.alpha
self.renderer.draw_world(self.world, alpha, overlay)
self.sidebar.draw(self.screen)
preview = self.input.spawn_preview()
if preview is not None:
self.renderer.draw_preview(preview)
def _debug_lines(self) -> list[str]:
awake = sum(not body.sleeping for body in self.world.bodies)
return [
f"Körper: {len(self.world.bodies)} (wach: {awake})",
f"Kontakte: {len(self.world.contacts)}",
f"Physik: {self.stats.physics_ms:.2f} ms/Frame · Schritt {self.world.step_count}",
]
def run(self, max_seconds: float | None = None) -> None:
"""Führt die Hauptschleife aus, bis das Fenster geschlossen wird.
Args:
max_seconds: Optional; beendet die Schleife nach dieser Laufzeit (für
Screenshots und den Smoke-Test).
"""
start = time.perf_counter()
self.clock.tick() # Zeitmessung ab jetzt, nicht ab dem Erzeugen der App
while self.running:
frame_time = self.clock.tick(self.config.window.fps_limit) / MILLISECONDS_PER_SECOND
self.frame(frame_time)
if max_seconds is not None and time.perf_counter() - start >= max_seconds:
break
def save_screenshot(self, path: Path) -> None:
"""Speichert den aktuellen Fensterinhalt als Bild."""
path.parent.mkdir(parents=True, exist_ok=True)
pygame.image.save(self.screen, str(path))
def close(self) -> None:
"""Gibt Fenster und pygame-Ressourcen frei."""
pygame.quit()
def main(argv: list[str] | None = None) -> int:
"""Startet die Sandbox (Kommandozeilen-Einstiegspunkt).
Args:
argv: Kommandozeilenargumente (Standard: ``sys.argv[1:]``).
Returns:
Exit-Code 0.
"""
parser = argparse.ArgumentParser(
prog="physics-sandbox", description="Interaktive 2D-Physik-Sandbox"
)
parser.add_argument("--demo", action="store_true", help="mit einer Beispielszene starten")
parser.add_argument(
"--screenshot",
type=Path,
metavar="DATEI",
help="nach --seconds Sekunden ein Bildschirmfoto speichern und beenden",
)
parser.add_argument(
"--seconds",
type=float,
default=APP.window.screenshot_delay,
help="Laufzeit vor dem Bildschirmfoto (Standard: %(default)s)",
)
args = parser.parse_args(argv)
app = App()
try:
if args.demo:
app.load_demo_scene()
if args.screenshot is not None:
app.run(max_seconds=args.seconds)
app.save_screenshot(args.screenshot)
else:
app.run()
finally:
app.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
+36
View File
@@ -0,0 +1,36 @@
"""Pygame-freie 2D-Physik-Engine.
Die Physik verwendet ausschließlich das Standardmodul ``math`` (plus ``typing`` und
``dataclasses`` für Typangaben und Datenstrukturen). Ein Architektur-Test stellt sicher,
dass hier nie pygame oder eine andere Bibliothek importiert wird.
Wichtigste Bausteine:
* :class:`Vector2` – eigene Vektorklasse.
* :class:`Circle`, :class:`Box`, :class:`HalfPlane` – Formen.
* :class:`Body`, :class:`Material` – starre Körper.
* :class:`World` – verwaltet Körper und führt Simulationsschritte aus.
* :class:`FixedTimestep` – übersetzt Frame-Zeiten in feste Physikschritte.
"""
from sandbox.physics.body import Body, Material
from sandbox.physics.collision import Contact, collide
from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape
from sandbox.physics.timestep import FixedTimestep
from sandbox.physics.vector import ZERO, Vector2
from sandbox.physics.world import World
__all__ = [
"ZERO",
"Body",
"Box",
"Circle",
"Contact",
"FixedTimestep",
"HalfPlane",
"Material",
"Shape",
"Vector2",
"World",
"collide",
]
+278
View File
@@ -0,0 +1,278 @@
"""Starre Körper und ihre Materialeigenschaften."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import ClassVar
from sandbox.physics.shapes import Box, Circle, HalfPlane, Shape
from sandbox.physics.vector import ZERO, Vector2
@dataclass(frozen=True, slots=True)
class Material:
"""Stoffeigenschaften eines Körpers.
Attributes:
density: Masse pro Fläche. Die Masse eines Körpers ist Fläche × Dichte, größere
Körper sind bei gleicher Dichte also schwerer.
restitution: Stoßzahl e ∈ [0, 1]. Verhältnis von Trenn- zu Aufprallgeschwindigkeit:
0 = völlig unelastisch (kein Abprallen), 1 = vollkommen elastisch.
friction: Reibungskoeffizient μ ≥ 0 (Coulomb: Reibkraft ≤ μ · Normalkraft).
"""
density: float
restitution: float
friction: float
def __post_init__(self) -> None:
if self.density < 0.0:
raise ValueError(f"Dichte darf nicht negativ sein: {self.density}")
if not 0.0 <= self.restitution <= 1.0:
raise ValueError(f"Restitution muss in [0, 1] liegen: {self.restitution}")
if self.friction < 0.0:
raise ValueError(f"Reibung darf nicht negativ sein: {self.friction}")
class Body:
"""Ein starrer Körper der Simulation.
Dynamische Körper haben eine endliche Masse (Fläche × Dichte) und werden von Schwerkraft
und Kontakten bewegt. Statische Körper (die Wände) haben unendliche Masse; gespeichert
wird dafür die *inverse* Masse 0. Mit inversen Massen lassen sich beide Fälle ohne
Sonderbehandlung in dieselben Formeln einsetzen: Ein Impuls ändert die Geschwindigkeit
um ``Impuls · inv_mass`` – bei statischen Körpern also gar nicht.
Attributes:
id: Eindeutige, fortlaufende Kennung (für Kontakt-Caches und deterministische
Reihenfolgen).
shape: Geometrie des Körpers.
material: Dichte, Restitution und Reibung.
position: Position des Schwerpunkts in px.
velocity: Geschwindigkeit des Schwerpunkts in px/s.
angle: Drehwinkel in rad (nur Kreise drehen sich).
angular_velocity: Winkelgeschwindigkeit in rad/s.
is_static: True für unbewegliche Körper (Wände).
mass: Masse (``math.inf`` bei statischen Körpern).
inv_mass: 1 / Masse (0 bei statischen Körpern).
inertia: Trägheitsmoment (``math.inf``, wenn sich der Körper nicht drehen kann).
inv_inertia: 1 / Trägheitsmoment (0, wenn sich der Körper nicht drehen kann).
sleeping: True, solange der Körper im Schlafmodus eingefroren ist.
sleep_time: Wie lange (s) der Körper schon ununterbrochen fast stillsteht.
sleep_island: Die Körper, mit denen dieser Körper gemeinsam eingeschlafen ist
(einschließlich ihm selbst), oder ``None``. Sie werden nur gemeinsam geweckt,
siehe :meth:`wake`.
held: True, solange der Körper mit der Maus gezogen wird. Gehaltene Körper
unterliegen keiner Schwerkraft und schlafen nie ein.
previous_position: Position zu Beginn des letzten Schritts (für die
Zeichen-Interpolation zwischen zwei Physikschritten).
previous_angle: Winkel zu Beginn des letzten Schritts.
previous_velocity: Geschwindigkeit zu Beginn des letzten Schritts, also vor
Schwerkraft und Kontakten. Daraus bestimmt der Solver die Aufprallgeschwindigkeit
für das Abprallen.
user_data: Beliebige Zusatzdaten der Anwendung (z. B. die Farbe). Die Physik
liest dieses Feld nie.
"""
__slots__ = (
"angle",
"angular_velocity",
"held",
"id",
"inertia",
"inv_inertia",
"inv_mass",
"is_static",
"mass",
"material",
"position",
"previous_angle",
"previous_position",
"previous_velocity",
"shape",
"sleep_island",
"sleep_time",
"sleeping",
"user_data",
"velocity",
)
_next_id: ClassVar[int] = 1
def __init__(
self,
shape: Shape,
position: Vector2,
material: Material,
*,
is_static: bool = False,
velocity: Vector2 = ZERO,
angular_velocity: float = 0.0,
user_data: object = None,
) -> None:
self.id = Body._next_id
Body._next_id += 1
self.shape = shape
self.material = material
self.position = position
self.previous_position = position
self.angle = 0.0
self.previous_angle = 0.0
self.is_static = is_static
self.sleeping = False
self.sleep_time = 0.0
self.sleep_island: list[Body] | None = None
self.held = False
self.user_data = user_data
if is_static:
self.mass = math.inf
self.inv_mass = 0.0
self.inertia = math.inf
self.inv_inertia = 0.0
else:
if isinstance(shape, HalfPlane):
raise ValueError("Halbebenen können nur statische Körper sein")
mass = shape.area() * material.density
if not mass > 0.0:
raise ValueError("Dynamische Körper brauchen eine Dichte > 0")
self.mass = mass
self.inv_mass = 1.0 / mass
self.inertia = shape.moment_of_inertia(mass)
self.inv_inertia = 0.0 if math.isinf(self.inertia) else 1.0 / self.inertia
# Statische Körper bewegen sich nie; Körper ohne Drehfreiheit drehen sich nie.
self.velocity = ZERO if is_static else velocity
self.previous_velocity = self.velocity
self.angular_velocity = angular_velocity if self.inv_inertia > 0.0 else 0.0
# ------------------------------------------------------------------ Fabrikmethoden
@classmethod
def circle(
cls,
position: Vector2,
radius: float,
material: Material,
*,
velocity: Vector2 = ZERO,
user_data: object = None,
) -> Body:
"""Erzeugt einen dynamischen Kreis."""
return cls(Circle(radius), position, material, velocity=velocity, user_data=user_data)
@classmethod
def box(
cls,
position: Vector2,
half_width: float,
half_height: float,
material: Material,
*,
velocity: Vector2 = ZERO,
user_data: object = None,
) -> Body:
"""Erzeugt ein dynamisches achsenparalleles Rechteck."""
return cls(
Box(half_width, half_height), position, material, velocity=velocity, user_data=user_data
)
@classmethod
def wall(cls, normal: Vector2, offset: float, material: Material) -> Body:
"""Erzeugt eine statische Wand (Halbebene ``normal·p ≥ offset`` ist frei)."""
return cls(HalfPlane(normal, offset), ZERO, material, is_static=True)
# ------------------------------------------------------------------ Abfragen
@property
def can_rotate(self) -> bool:
"""True, wenn Drehimpulse den Körper drehen können (nur dynamische Kreise)."""
return self.inv_inertia > 0.0
def half_extents(self) -> tuple[float, float]:
"""Halbe Breite und Höhe der umschließenden achsenparallelen Box."""
return self.shape.half_extents()
def bounds(self) -> tuple[float, float, float, float]:
"""Umschließende Box als ``(min_x, min_y, max_x, max_y)``."""
half_x, half_y = self.shape.half_extents()
x = self.position.x
y = self.position.y
return (x - half_x, y - half_y, x + half_x, y + half_y)
def contains_point(self, point: Vector2, tolerance: float = 0.0) -> bool:
"""Prüft, ob ein Weltpunkt im Körper liegt (``tolerance`` vergrößert den Körper)."""
return self.shape.contains(point - self.position, tolerance)
def kinetic_energy(self) -> float:
"""Bewegungsenergie ``½·m·v² + ½·I·ω²`` (0 für statische Körper)."""
if self.is_static:
return 0.0
energy = 0.5 * self.mass * self.velocity.length_squared()
if self.can_rotate:
energy += 0.5 * self.inertia * self.angular_velocity * self.angular_velocity
return energy
def momentum(self) -> Vector2:
"""Impuls ``p = m·v``."""
if self.is_static:
return ZERO
return self.velocity * self.mass
# ------------------------------------------------------------------ Zustandsänderungen
def apply_impulse(self, impulse: Vector2, offset: Vector2 = ZERO) -> None:
"""Wendet einen Impuls an einem Punkt an.
Args:
impulse: Impuls (Kraftstoß) in Masse·px/s.
offset: Angriffspunkt relativ zum Schwerpunkt. Ein Impuls, der nicht durch den
Schwerpunkt geht, erzeugt zusätzlich einen Drehimpuls ``offset × impulse``.
"""
if self.is_static:
return
self.velocity = self.velocity + impulse * self.inv_mass
self.angular_velocity += self.inv_inertia * offset.cross(impulse)
def wake(self) -> None:
"""Weckt den Körper – und alle Körper, mit denen er eingeschlafen ist.
Warum die ganze Insel? Zwei schlafende Körper werden nicht auf Kontakte
untereinander geprüft. Würde nur die unterste Kiste eines schlafenden Stapels
geweckt und im selben Schritt weggestoßen (oder in der Pause weggezogen), bemerkte
niemand, dass die Kisten darüber ihre Stütze verloren haben – sie schwebten.
"""
island = self.sleep_island
for body in island if island is not None else (self,):
body.sleeping = False
body.sleep_time = 0.0
body.sleep_island = None
def put_to_sleep(self, island: list[Body] | None = None) -> None:
"""Friert den Körper ein: keine Bewegung, bis ihn etwas aufweckt.
Args:
island: Die Körper, die gemeinsam mit diesem einschlafen (einschließlich
ihm selbst). Weckt später einer von ihnen auf, wachen alle auf.
"""
self.sleeping = True
self.sleep_island = island
self.velocity = ZERO
self.previous_velocity = ZERO
self.angular_velocity = 0.0
# Ohne diese Angleichung würde die Zeichen-Interpolation einen längst ruhenden
# Körper noch zwischen zwei alten Positionen hin- und herschieben.
self.previous_position = self.position
self.previous_angle = self.angle
def teleport(self, position: Vector2) -> None:
"""Setzt die Position direkt (ohne Interpolations-Schlieren) und weckt den Körper."""
self.position = position
self.previous_position = position
self.wake()
def __repr__(self) -> str:
state = " schläft" if self.sleeping else ""
return f"Body(id={self.id}, {self.shape!r}, position={self.position!r}{state})"
+51
View File
@@ -0,0 +1,51 @@
"""Grobe Vorauswahl möglicher Kollisionspaare ("Broadphase") per Sweep-and-Prune.
Warum: Jedes Paar von n Körpern genau zu prüfen kostet n·(n−1)/2 Tests – bei 150 Körpern
über 11 000 pro Schritt, 120-mal pro Sekunde. In reinem Python ist das zu langsam.
Sweep-and-Prune sortiert die umschließenden Boxen nach ihrer linken Kante und läuft einmal
von links nach rechts. Für jede Box müssen nur die Nachfolger geprüft werden, deren linke
Kante noch vor ihrer rechten Kante liegt; alle weiteren beginnen noch weiter rechts und
können nicht überlappen. Der Aufwand sinkt so auf etwa O(n·log n + k) für k tatsächlich
benachbarte Paare. Da sich die Körper zwischen zwei Schritten kaum bewegen, ist die Liste
fast sortiert, und Pythons Timsort sortiert sie besonders schnell.
"""
from __future__ import annotations
from sandbox.physics.body import Body
def find_candidate_pairs(bodies: list[Body]) -> list[tuple[Body, Body]]:
"""Findet alle Körperpaare, deren umschließende Boxen sich überlappen oder berühren.
Paare aus zwei schlafenden Körpern werden übersprungen: Sie bewegen sich nicht, ihr
Kontakt ändert sich also nicht. Jedes Paar ist nach ``id`` geordnet, damit die
Reihenfolge (und damit die ganze Simulation) reproduzierbar ist.
Args:
bodies: Die dynamischen Körper der Welt.
Returns:
Kandidatenpaare für die genaue Prüfung mit :func:`~sandbox.physics.collision.collide`.
"""
entries = sorted(((body.bounds(), body) for body in bodies), key=_left_edge)
pairs: list[tuple[Body, Body]] = []
count = len(entries)
for i in range(count):
(_, min_y, max_x, max_y), body = entries[i]
for j in range(i + 1, count):
(other_min_x, other_min_y, _, other_max_y), other = entries[j]
if other_min_x > max_x:
break # Alle weiteren Boxen beginnen noch weiter rechts.
if other_min_y > max_y or other_max_y < min_y:
continue
if body.sleeping and other.sleeping:
continue
pairs.append((body, other) if body.id < other.id else (other, body))
return pairs
def _left_edge(entry: tuple[tuple[float, float, float, float], Body]) -> float:
"""Sortierschlüssel: linke Kante der umschließenden Box."""
return entry[0][0]
+266
View File
@@ -0,0 +1,266 @@
"""Kollisionserkennung ("Narrowphase"): Berühren sich zwei Körper, und wenn ja, wie?
Jede Testfunktion liefert entweder ``None`` (keine Berührung) oder einen :class:`Contact`
mit drei Informationen, die die Kollisionsauflösung braucht:
* **Normale** n – Einheitsvektor, der von Körper A zu Körper B zeigt. Entlang n werden
die Körper auseinandergedrückt.
* **Eindringtiefe** – wie weit sich die Körper entlang n überlappen (≥ 0).
* **Kontaktpunkt** – wo die Kraft angreift. Er bestimmt die Hebelarme für die Drehung.
Konvention: :func:`collide` ordnet die Körper so, dass Körper A die "einfachere" Form hat
(Kreis vor Rechteck vor Wand). Für ein Körperpaar ist die Orientierung dadurch in jedem
Schritt gleich, was das Warm-Starting des Solvers (Wiederverwenden alter Impulse) erlaubt.
"""
from __future__ import annotations
import math
from typing import Final
from sandbox.config import NUMERIC_EPSILON
from sandbox.physics.body import Body
from sandbox.physics.shapes import Box, Circle, HalfPlane
from sandbox.physics.vector import Vector2
#: Ersatznormale, wenn zwei Kreismittelpunkte exakt aufeinanderliegen und die Richtung
#: daher unbestimmt ist. Jede Richtung wäre richtig; eine feste macht das Ergebnis
#: reproduzierbar. (0, −1) schiebt Körper B nach oben, A nach unten.
COINCIDENT_NORMAL: Final = Vector2(0.0, -1.0)
#: Reihenfolge der Formen für die kanonische Anordnung in :func:`collide`.
_SHAPE_ORDER: Final = {Circle: 0, Box: 1, HalfPlane: 2}
class Contact:
"""Berührung zweier Körper in einem Simulationsschritt.
Attributes:
body_a: Erster Körper.
body_b: Zweiter Körper.
normal: Einheitsnormale von A nach B.
penetration: Eindringtiefe entlang der Normalen in px (≥ 0).
point: Kontaktpunkt in Weltkoordinaten.
normal_impulse: Vom Solver aufgebrachter Normalimpuls (Ergebnis, ≥ 0).
tangent_impulse: Vom Solver aufgebrachter Reibungsimpuls (Ergebnis).
"""
__slots__ = (
"body_a",
"body_b",
"normal",
"normal_impulse",
"penetration",
"point",
"tangent_impulse",
)
def __init__(
self, body_a: Body, body_b: Body, normal: Vector2, penetration: float, point: Vector2
) -> None:
self.body_a = body_a
self.body_b = body_b
self.normal = normal
self.penetration = penetration
self.point = point
self.normal_impulse = 0.0
self.tangent_impulse = 0.0
@property
def key(self) -> tuple[int, int]:
"""Schlüssel des Körperpaares, um Kontakte über Schritte hinweg wiederzuerkennen."""
return (self.body_a.id, self.body_b.id)
def __repr__(self) -> str:
return (
f"Contact({self.body_a.id}->{self.body_b.id}, normal={self.normal!r}, "
f"penetration={self.penetration:.3f})"
)
def collide(first: Body, second: Body) -> Contact | None:
"""Prüft zwei beliebige Körper auf Berührung.
Die Körper werden vorher kanonisch angeordnet (siehe Moduldokumentation). Das Ergebnis
kann ``body_a`` und ``body_b`` daher in vertauschter Reihenfolge enthalten.
Returns:
Den Kontakt oder ``None``, wenn sich die Körper nicht berühren.
"""
body_a, body_b = first, second
if _SHAPE_ORDER[type(body_b.shape)] < _SHAPE_ORDER[type(body_a.shape)]:
body_a, body_b = body_b, body_a
shape_a = body_a.shape
shape_b = body_b.shape
if isinstance(shape_a, Circle):
if isinstance(shape_b, Circle):
return collide_circles(body_a, body_b)
if isinstance(shape_b, Box):
return collide_circle_box(body_a, body_b)
return collide_circle_half_plane(body_a, body_b)
if isinstance(shape_a, Box):
if isinstance(shape_b, Box):
return collide_boxes(body_a, body_b)
return collide_box_half_plane(body_a, body_b)
return None # Zwei Wände kollidieren nie miteinander.
def collide_circles(body_a: Body, body_b: Body) -> Contact | None:
"""Kreis gegen Kreis.
Zwei Kreise überlappen, wenn der Mittelpunktabstand kleiner als die Summe der Radien
ist. Die Normale zeigt entlang der Verbindungslinie der Mittelpunkte.
"""
radius_a = body_a.shape.radius
radius_b = body_b.shape.radius
dx = body_b.position.x - body_a.position.x
dy = body_b.position.y - body_a.position.y
distance_sq = dx * dx + dy * dy
radius_sum = radius_a + radius_b
if distance_sq > radius_sum * radius_sum:
return None
if distance_sq > NUMERIC_EPSILON * NUMERIC_EPSILON:
distance = math.sqrt(distance_sq)
normal = Vector2(dx / distance, dy / distance)
else:
distance = 0.0
normal = COINCIDENT_NORMAL
penetration = radius_sum - distance
# Kontaktpunkt: Mitte des überlappenden Bereichs auf der Verbindungslinie.
point = body_a.position + normal * (radius_a - 0.5 * penetration)
return Contact(body_a, body_b, normal, penetration, point)
def collide_circle_box(circle_body: Body, box_body: Body) -> Contact | None:
"""Kreis (Körper A) gegen achsenparalleles Rechteck (Körper B).
Idee: Den Kreismittelpunkt komponentenweise auf das Rechteck klemmen. Das liefert den
Punkt des Rechtecks, der dem Kreismittelpunkt am nächsten liegt. Ist er weniger als
einen Radius entfernt, berühren sich die Körper.
Sonderfall: Liegt der Mittelpunkt *im* Rechteck, ist der geklemmte Punkt der
Mittelpunkt selbst und liefert keine Richtung. Dann wird der Kreis über die nächste
Kante hinausgeschoben (Achse mit der kleinsten Eindringtiefe).
"""
radius = circle_body.shape.radius
half_width = box_body.shape.half_width
half_height = box_body.shape.half_height
box_x = box_body.position.x
box_y = box_body.position.y
# Kreismittelpunkt relativ zum Rechteckmittelpunkt.
dx = circle_body.position.x - box_x
dy = circle_body.position.y - box_y
closest_x = min(max(dx, -half_width), half_width)
closest_y = min(max(dy, -half_height), half_height)
if closest_x != dx or closest_y != dy:
# Mittelpunkt außerhalb: Abstand zum nächsten Punkt des Rechtecks.
offset_x = dx - closest_x
offset_y = dy - closest_y
distance_sq = offset_x * offset_x + offset_y * offset_y
if distance_sq > radius * radius:
return None
distance = math.sqrt(distance_sq) # > 0, weil der Mittelpunkt außerhalb liegt
# (offset) zeigt vom Rechteck zum Kreis; die Normale soll von A (Kreis) zu B zeigen.
normal = Vector2(-offset_x / distance, -offset_y / distance)
penetration = radius - distance
point = Vector2(box_x + closest_x, box_y + closest_y)
return Contact(circle_body, box_body, normal, penetration, point)
# Mittelpunkt innerhalb (oder genau auf dem Rand): über die nächste Kante hinausdrücken.
gap_x = half_width - abs(dx)
gap_y = half_height - abs(dy)
if gap_x < gap_y:
side = 1.0 if dx >= 0.0 else -1.0
# Der Kreis muss in Richtung "side" hinaus, er bewegt sich entlang −n → n = −side.
normal = Vector2(-side, 0.0)
penetration = radius + gap_x
point = Vector2(box_x + side * half_width, circle_body.position.y)
else:
side = 1.0 if dy >= 0.0 else -1.0
normal = Vector2(0.0, -side)
penetration = radius + gap_y
point = Vector2(circle_body.position.x, box_y + side * half_height)
return Contact(circle_body, box_body, normal, penetration, point)
def collide_boxes(body_a: Body, body_b: Body) -> Contact | None:
"""Rechteck gegen Rechteck (beide achsenparallel).
Zwei achsenparallele Rechtecke überlappen genau dann, wenn sich ihre Projektionen auf
beide Achsen überlappen (Trennachsensatz). Getrennt wird entlang der Achse mit der
kleineren Überlappung – das ist der kürzeste Weg aus der Durchdringung. Bei Gleichstand
gewinnt die y-Achse, weil aufeinanderliegende Körper der häufigste Fall sind.
"""
shape_a = body_a.shape
shape_b = body_b.shape
dx = body_b.position.x - body_a.position.x
dy = body_b.position.y - body_a.position.y
overlap_x = shape_a.half_width + shape_b.half_width - abs(dx)
if overlap_x < 0.0:
return None
overlap_y = shape_a.half_height + shape_b.half_height - abs(dy)
if overlap_y < 0.0:
return None
if overlap_x < overlap_y:
normal = Vector2(1.0 if dx >= 0.0 else -1.0, 0.0)
penetration = overlap_x
else:
normal = Vector2(0.0, 1.0 if dy >= 0.0 else -1.0)
penetration = overlap_y
# Kontaktpunkt: Mittelpunkt des Überlappungsrechtecks. Da Rechtecke sich nie drehen,
# beeinflusst er nur die Debug-Anzeige, nicht die Physik.
a_x = body_a.position.x
a_y = body_a.position.y
b_x = body_b.position.x
b_y = body_b.position.y
left = max(a_x - shape_a.half_width, b_x - shape_b.half_width)
right = min(a_x + shape_a.half_width, b_x + shape_b.half_width)
top = max(a_y - shape_a.half_height, b_y - shape_b.half_height)
bottom = min(a_y + shape_a.half_height, b_y + shape_b.half_height)
point = Vector2(0.5 * (left + right), 0.5 * (top + bottom))
return Contact(body_a, body_b, normal, penetration, point)
def collide_circle_half_plane(circle_body: Body, wall_body: Body) -> Contact | None:
"""Kreis gegen Wand (Halbebene).
Der Kreis berührt die Wand, wenn der Abstand seines Mittelpunkts zur Wandlinie kleiner
als der Radius ist. Liegt der Mittelpunkt sogar hinter der Wand, ist der Abstand
negativ und die Eindringtiefe entsprechend groß – der Kreis wird trotzdem korrekt in
den freien Raum zurückgeschoben.
"""
wall = wall_body.shape
center = circle_body.position
distance = wall.distance(center)
penetration = circle_body.shape.radius - distance
if penetration < 0.0:
return None
# Die Wandnormale zeigt in den freien Raum, also zum Kreis; A→B ist die Gegenrichtung.
normal = -wall.normal
point = center - wall.normal * distance # Lotfußpunkt auf der Wandlinie
return Contact(circle_body, wall_body, normal, penetration, point)
def collide_box_half_plane(box_body: Body, wall_body: Body) -> Contact | None:
"""Rechteck gegen Wand (Halbebene).
Die "Reichweite" eines achsenparallelen Rechtecks entlang der Normalen n ist
``|n.x|·hw + |n.y|·hh`` (Projektion der Halbdiagonale). Ist der Mittelpunkt näher an der
Wand als diese Reichweite, ragt das Rechteck in die Wand.
"""
wall = wall_body.shape
shape = box_body.shape
center = box_body.position
reach = abs(wall.normal.x) * shape.half_width + abs(wall.normal.y) * shape.half_height
distance = wall.distance(center)
penetration = reach - distance
if penetration < 0.0:
return None
normal = -wall.normal
point = center - wall.normal * distance
return Contact(box_body, wall_body, normal, penetration, point)
+139
View File
@@ -0,0 +1,139 @@
"""Geometrische Formen der Körper: Kreis, achsenparalleles Rechteck und Halbebene.
Formen beschreiben nur die Geometrie relativ zum Schwerpunkt. Position, Geschwindigkeit
und Masse gehören zum :class:`~sandbox.physics.body.Body`.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import TypeAlias
from sandbox.config import NUMERIC_EPSILON
from sandbox.physics.vector import Vector2
@dataclass(frozen=True, slots=True)
class Circle:
"""Homogene Kreisscheibe. Kreise können sich drehen (z. B. rollen).
Attributes:
radius: Radius in px (> 0).
"""
radius: float
def __post_init__(self) -> None:
if not self.radius > 0.0:
raise ValueError(f"Radius muss positiv sein, ist aber {self.radius}")
def area(self) -> float:
"""Fläche ``π·r²`` – bestimmt zusammen mit der Dichte die Masse."""
return math.pi * self.radius * self.radius
def moment_of_inertia(self, mass: float) -> float:
"""Trägheitsmoment einer homogenen Scheibe um ihren Mittelpunkt: ``I = ½·m·r²``.
Herleitung: Die Scheibe besteht aus dünnen Ringen mit Radius s und Masse
``dm = m/(π·r²) · 2π·s·ds``. Jeder Ring trägt ``s²·dm`` bei, also
``I = (2m/r²)·∫₀ʳ s³ ds = (2m/r²)·r⁴/4 = ½·m·r²``.
"""
return 0.5 * mass * self.radius * self.radius
def half_extents(self) -> tuple[float, float]:
"""Halbe Breite und Höhe der umschließenden achsenparallelen Box."""
return (self.radius, self.radius)
def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool:
"""Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Kreis liegt."""
reach = self.radius + tolerance
return offset.length_squared() <= reach * reach
@dataclass(frozen=True, slots=True)
class Box:
"""Achsenparalleles Rechteck (AABB).
Rechtecke bleiben per Definition achsenparallel. Deshalb liefert
:meth:`moment_of_inertia` "unendlich": Das inverse Trägheitsmoment ist dann 0, und kein
Drehimpuls kann das Rechteck je drehen.
Attributes:
half_width: Halbe Breite in px (> 0).
half_height: Halbe Höhe in px (> 0).
"""
half_width: float
half_height: float
def __post_init__(self) -> None:
if not (self.half_width > 0.0 and self.half_height > 0.0):
raise ValueError(
f"Halbe Kantenlängen müssen positiv sein: {self.half_width}, {self.half_height}"
)
def area(self) -> float:
"""Fläche ``(2·hw)·(2·hh)``."""
return 4.0 * self.half_width * self.half_height
def moment_of_inertia(self, mass: float) -> float:
"""Unendlich, damit sich das Rechteck nie dreht (siehe Klassenbeschreibung).
``mass`` wird nur für eine einheitliche Schnittstelle mit :class:`Circle` angenommen.
"""
del mass
return math.inf
def half_extents(self) -> tuple[float, float]:
"""Halbe Breite und Höhe."""
return (self.half_width, self.half_height)
def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool:
"""Prüft, ob ein Punkt (relativ zum Mittelpunkt) im Rechteck liegt."""
return (
abs(offset.x) <= self.half_width + tolerance
and abs(offset.y) <= self.half_height + tolerance
)
@dataclass(frozen=True, slots=True)
class HalfPlane:
"""Unendliche Halbebene – modelliert Boden, Decke und Seitenwände.
Die Ebene ist durch ``normal·p = offset`` gegeben. Die Normale zeigt in den freien
Raum; alles mit ``normal·p < offset`` ist "in der Wand". Weil eine Halbebene unendlich
dick ist, kann kein Körper durch sie hindurchtunneln: Selbst ein Körper, der in einem
Schritt komplett hinter die Wandlinie springt, wird als (tiefe) Durchdringung erkannt
und zurückgeschoben.
Attributes:
normal: Einheitsnormale, zeigt in den freien Raum.
offset: Abstand der Ebene vom Ursprung entlang der Normalen.
"""
normal: Vector2
offset: float
def __post_init__(self) -> None:
if abs(self.normal.length() - 1.0) > NUMERIC_EPSILON:
raise ValueError(f"Die Normale muss Länge 1 haben: {self.normal}")
def distance(self, point: Vector2) -> float:
"""Vorzeichenbehafteter Abstand eines Punktes (positiv = im freien Raum)."""
return self.normal.dot(point) - self.offset
def half_extents(self) -> tuple[float, float]:
"""Eine Halbebene ist unbegrenzt."""
return (math.inf, math.inf)
def contains(self, offset: Vector2, tolerance: float = 0.0) -> bool:
"""Prüft, ob ein Punkt in der Wand (hinter der Ebene) liegt.
Wandkörper liegen im Ursprung, deshalb ist ``offset`` hier der Punkt selbst.
"""
return self.distance(offset) <= tolerance
#: Alle unterstützten Formen.
Shape: TypeAlias = Circle | Box | HalfPlane
+126
View File
@@ -0,0 +1,126 @@
"""Schlafmodus: ruhende Körper einfrieren, bis etwas sie anstößt.
Warum: Ein Solver mit endlich vielen Iterationen lässt immer winzige Restbewegungen übrig.
Ein Stapel "atmet" dadurch sichtbar oder kriecht. Friert man Körper ein, die eine Weile
praktisch stillstehen, liegen sie wirklich ruhig – und sparen Rechenzeit, weil sie weder
integriert noch aufgelöst werden müssen.
Warum gruppenweise ("Inseln")? Würde jeder Körper einzeln einschlafen, könnte ein
schlafender Körper unter einem wachen liegen: Der wache weckt ihn, beide ruhen, der eine
schläft früher ein, der andere weckt ihn wieder – endloses Hin und Her. Deshalb werden
Körper, die sich (direkt oder über andere) berühren, zu einer Insel zusammengefasst, und
eine Insel schläft nur als Ganzes ein – und wacht auch nur als Ganzes wieder auf. Wände
verbinden keine Inseln, sonst wäre alles auf dem Boden eine einzige Insel.
"""
from __future__ import annotations
from sandbox.config import PhysicsConfig
from sandbox.physics.body import Body
from sandbox.physics.collision import Contact
class UnionFind:
"""Disjunkte Mengen ("Union-Find") zum Bilden von Kontakt-Inseln.
Jede Menge wird durch einen Repräsentanten identifiziert. :meth:`union` verschmilzt zwei
Mengen, :meth:`find` liefert den Repräsentanten – dank Pfadverkürzung in nahezu
konstanter Zeit.
"""
def __init__(self, keys: list[int]) -> None:
self._parent = {key: key for key in keys}
def find(self, key: int) -> int:
"""Repräsentant der Menge, zu der ``key`` gehört."""
root = key
while self._parent[root] != root:
root = self._parent[root]
# Pfadverkürzung: alle besuchten Elemente direkt an die Wurzel hängen.
while self._parent[key] != root:
next_key = self._parent[key]
self._parent[key] = root
key = next_key
return root
def union(self, first: int, second: int) -> None:
"""Verschmilzt die Mengen von ``first`` und ``second``."""
root_first = self.find(first)
root_second = self.find(second)
if root_first != root_second:
self._parent[root_second] = root_first
def wake_touched_bodies(contacts: list[Contact]) -> list[Body]:
"""Weckt schlafende Körper, die von einem wachen dynamischen Körper berührt werden.
Mit dem berührten Körper wacht seine ganze Schlaf-Insel auf (siehe
:meth:`Body.wake <sandbox.physics.body.Body.wake>`). Die geweckten Körper gehören ab
sofort zur Insel des wachen Körpers und schlafen erst wieder ein, wenn die ganze Insel
zur Ruhe gekommen ist.
Returns:
Alle in diesem Aufruf geweckten Körper.
"""
woken: list[Body] = []
for contact in contacts:
body_a = contact.body_a
body_b = contact.body_b
if body_a.sleeping == body_b.sleeping:
continue # beide wach, beide schlafend – oder schon von einer Insel geweckt
sleeper, other = (body_a, body_b) if body_a.sleeping else (body_b, body_a)
if other.is_static:
continue # Wände wecken niemanden – sonst schliefe nichts am Boden je ein.
island = sleeper.sleep_island
sleeper.wake()
woken.extend(island if island is not None else (sleeper,))
return woken
def find_islands(bodies: list[Body], contacts: list[Contact]) -> list[list[Body]]:
"""Gruppiert wache dynamische Körper nach Berührung zu Inseln.
Args:
bodies: Die wachen dynamischen Körper.
contacts: Die Kontakte des aktuellen Schritts.
Returns:
Inseln in der Reihenfolge ihres ersten Körpers in ``bodies``.
"""
union_find = UnionFind([body.id for body in bodies])
for contact in contacts:
if contact.body_a.is_static or contact.body_b.is_static:
continue
union_find.union(contact.body_a.id, contact.body_b.id)
islands: dict[int, list[Body]] = {}
for body in bodies:
islands.setdefault(union_find.find(body.id), []).append(body)
return list(islands.values())
def update_sleep(
bodies: list[Body], contacts: list[Contact], dt: float, config: PhysicsConfig
) -> None:
"""Aktualisiert die Ruhezeiten und schickt vollständig ruhende Inseln schlafen.
Args:
bodies: Die wachen dynamischen Körper dieses Schritts.
contacts: Die Kontakte dieses Schritts.
dt: Zeitschritt in s.
config: Schwellwerte für Ruhe und Einschlafzeit.
"""
linear_limit_sq = config.sleep_linear_speed * config.sleep_linear_speed
angular_limit = config.sleep_angular_speed
for body in bodies:
resting = (
not body.held
and body.velocity.length_squared() <= linear_limit_sq
and abs(body.angular_velocity) <= angular_limit
)
body.sleep_time = body.sleep_time + dt if resting else 0.0
for island in find_islands(bodies, contacts):
if all(body.sleep_time >= config.time_to_sleep for body in island):
for body in island:
body.put_to_sleep(island) # alle teilen sich dieselbe Liste
+499
View File
@@ -0,0 +1,499 @@
"""Impulsbasierte Kollisionsauflösung ("Sequential Impulses") und Positionskorrektur.
Bedingungen pro Kontakt
-----------------------
1. **Nichtdurchdringung** entlang der Normalen n: Am Kontaktpunkt dürfen sich die Körper
nicht weiter annähern, ``v_n ≥ 0``. Bei einem echten Aufprall gilt zusätzlich Newtons
Stoßgesetz ``v_n' = −e·v_n`` (e = Restitution).
2. **Reibung** entlang der Tangente t: Das Gleiten ``v_t`` soll verschwinden, der
Reibungsimpuls ist aber nach Coulomb auf ``|P_t| ≤ μ·P_n`` begrenzt. Reicht das nicht,
rutschen die Körper weiter (Gleitreibung).
Herleitung des Kollisionsimpulses
---------------------------------
Relativgeschwindigkeit des Kontaktpunkts (B relativ zu A) mit den Hebelarmen r_A, r_B vom
Schwerpunkt zum Kontaktpunkt und ``ω × r = ω·(−r.y, r.x)``::
v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), v_n = v_rel · n
Ein Impuls ``P = λ·n`` wirkt auf B positiv und auf A negativ (actio = reactio)::
v_A' = v_A − λ·n/m_A ω_A' = ω_A − (r_A × λn)/I_A
v_B' = v_B + λ·n/m_B ω_B' = ω_B + (r_B × λn)/I_B
Eingesetzt ändert sich die Normalgeschwindigkeit linear in λ::
v_n' = v_n + λ·K, K = 1/m_A + 1/m_B + (r_A × n)²/I_A + (r_B × n)²/I_B
Aus ``v_n' = −e·v_n`` folgt ``λ = −(1 + e)·v_n / K``. Der Kehrwert 1/K ist die *effektive
Masse* des Kontakts: die träge Masse, die der Stoß entlang n "spürt". Für die Reibung gilt
dasselbe mit t statt n und dem Ziel ``v_t' = 0``.
In dieser Engine verschwinden die Drehterme von K für die Normale: Bei Kreisen geht die
Normale durch den Mittelpunkt (r ∥ n, also r × n = 0), und Rechtecke drehen sich nie
(1/I = 0). Normalimpulse erzeugen daher kein Drehmoment – nur die Reibung bringt Kreise
zum Rollen.
Rollwiderstand
--------------
Reines Rollen verbraucht keine Reibungsenergie: Ohne weitere Kraft rollte ein Kreis ewig,
und ein Rechteck auf einem Kreis führe darauf wie auf einer Rolle davon. Real verformen
sich Kreis und Unterlage ein wenig, was ein bremsendes Moment ``M ≤ μ_r·N·r`` erzeugt
(μ_r = Rollwiderstandsbeiwert, N = Normalkraft, r = Radius). Pro Schritt entspricht das
einem Drehimpuls ``≤ μ_r·P_n·r``. Umgesetzt ist es wie die Reibung als begrenzter Impuls,
nur auf die relative Winkelgeschwindigkeit ``ω_B − ω_A`` mit der effektiven Masse
``1/(1/I_A + 1/I_B)``. Weil die Grenze mit der Auflagekraft wächst, kommen schwer
belastete "Rollen" schnell zur Ruhe, während ein frei rollender Ball weit rollt.
Warum iterativ?
---------------
Liegt ein Körper auf mehreren anderen (Stapel), beeinflussen sich die Kontakte gegenseitig.
Statt ein Gleichungssystem zu lösen, werden die Kontakte reihum mehrfach aufgelöst
(Gauß-Seidel-Verfahren). Geklemmt wird dabei der über alle Durchläufe *akkumulierte*
Impuls (≥ 0), nicht der einzelne Teilimpuls: Ein späterer Durchlauf darf einen zu großen
früheren Impuls wieder zurücknehmen. So konvergiert das Verfahren gegen die physikalische
Lösung, in der Kontakte nur drücken, aber nie ziehen.
Warm-Starting: Die akkumulierten Impulse des vorigen Schritts sind ein sehr guter
Startwert, weil sich ruhende Kontakte kaum ändern. Ein Stapel steht so schon nach wenigen
Durchläufen stabil, statt jeden Schritt bei 0 beginnen zu müssen.
Leistung: Die inneren Schleifen laufen tausendfach pro Schritt. Deshalb arbeiten sie auf
lokalen Float-Listen statt auf Vektorobjekten; die Formeln sind dieselben wie oben,
komponentenweise ausgeschrieben.
"""
from __future__ import annotations
import math
from typing import TypeAlias
from sandbox.config import PhysicsConfig
from sandbox.physics.body import Body
from sandbox.physics.collision import Contact
from sandbox.physics.vector import Vector2
#: Gespeicherte Impulse eines Kontakts für das Warm-Starting:
#: (Normalimpuls, Reibungsimpuls, Rollwiderstandsimpuls, Normale x, Normale y).
_CachedImpulses: TypeAlias = tuple[float, float, float, float, float]
class _Constraint:
"""Vorberechnete Solver-Daten eines Kontakts (nur Floats, für schnelle Iterationen)."""
__slots__ = (
"contact",
"friction",
"index_a",
"index_b",
"inv_inertia_a",
"inv_inertia_b",
"inv_mass_a",
"inv_mass_b",
"normal_impulse",
"normal_mass",
"normal_x",
"normal_y",
"offset_a_x",
"offset_a_y",
"offset_b_x",
"offset_b_y",
"penetration",
"rolling_impulse",
"rolling_limit",
"rolling_mass",
"tangent_impulse",
"tangent_mass",
"tangent_x",
"tangent_y",
"velocity_bias",
)
contact: Contact
friction: float
index_a: int
index_b: int
inv_inertia_a: float
inv_inertia_b: float
inv_mass_a: float
inv_mass_b: float
normal_impulse: float
normal_mass: float
normal_x: float
normal_y: float
offset_a_x: float
offset_a_y: float
offset_b_x: float
offset_b_y: float
penetration: float
rolling_impulse: float
rolling_limit: float
rolling_mass: float
tangent_impulse: float
tangent_mass: float
tangent_x: float
tangent_y: float
velocity_bias: float
class ContactSolver:
"""Löst Kontakte über Impulse auf und korrigiert verbleibende Durchdringungen.
Ablauf pro Simulationsschritt (gesteuert von :class:`~sandbox.physics.world.World`):
1. :meth:`solve_velocities` – Geschwindigkeiten so ändern, dass kein Kontakt sich weiter
schließt (mit Restitution und Reibung).
2. Die Welt integriert die Positionen mit den neuen Geschwindigkeiten.
3. :meth:`correct_positions` – verbliebene Überlappungen direkt über die Positionen
abbauen, ohne Geschwindigkeiten zu ändern.
"""
def __init__(self, config: PhysicsConfig) -> None:
self._config = config
self._constraints: list[_Constraint] = []
self._bodies: list[Body] = []
self._velocity_x: list[float] = []
self._velocity_y: list[float] = []
self._angular_velocity: list[float] = []
self._start_x: list[float] = []
self._start_y: list[float] = []
# Akkumulierte Impulse des letzten Schritts je Körperpaar (für das Warm-Starting).
self._impulse_cache: dict[tuple[int, int], _CachedImpulses] = {}
def reset(self) -> None:
"""Vergisst alle gespeicherten Impulse (z. B. nachdem die Welt geleert wurde)."""
self._constraints = []
self._bodies = []
self._impulse_cache = {}
# ------------------------------------------------------------------ Geschwindigkeiten
def solve_velocities(self, contacts: list[Contact]) -> None:
"""Berechnet die Kontaktimpulse und schreibt die neuen Geschwindigkeiten zurück.
Nach dem Aufruf enthalten die Kontakte die aufgebrachten Impulse
(``normal_impulse``, ``tangent_impulse``).
"""
self._prepare(contacts)
if self._config.warm_starting:
self._warm_start()
for _ in range(self._config.velocity_iterations):
self._solve_velocity_iteration()
self._store_velocities()
def _prepare(self, contacts: list[Contact]) -> None:
"""Legt die Float-Arbeitskopien an und berechnet die effektiven Massen."""
bodies: list[Body] = []
index_of: dict[int, int] = {}
index_pairs: list[tuple[int, int]] = []
for contact in contacts:
indices = []
for body in (contact.body_a, contact.body_b):
index = index_of.get(body.id)
if index is None:
index = len(bodies)
index_of[body.id] = index
bodies.append(body)
indices.append(index)
index_pairs.append((indices[0], indices[1]))
self._bodies = bodies
self._velocity_x = [body.velocity.x for body in bodies]
self._velocity_y = [body.velocity.y for body in bodies]
self._angular_velocity = [body.angular_velocity for body in bodies]
# Positionen bei der Kollisionserkennung – Bezug für die spätere Positionskorrektur.
self._start_x = [body.position.x for body in bodies]
self._start_y = [body.position.y for body in bodies]
self._constraints = [
self._make_constraint(contact, index_a, index_b)
for contact, (index_a, index_b) in zip(contacts, index_pairs, strict=True)
]
def _make_constraint(self, contact: Contact, index_a: int, index_b: int) -> _Constraint:
"""Berechnet alle Größen eines Kontakts, die sich während der Iterationen nicht ändern.
Läuft für jeden Kontakt in jedem Schritt und rechnet deshalb – wie die Iterationen –
komponentenweise mit Floats statt mit temporären Vektorobjekten.
"""
body_a = contact.body_a
body_b = contact.body_b
normal_x = contact.normal.x
normal_y = contact.normal.y
# Tangente = Normale um 90° gedreht (−n.y, n.x). Ihr Vorzeichen ist beliebig, weil
# der Reibungsimpuls symmetrisch auf [−μ·P_n, +μ·P_n] geklemmt wird.
tangent_x = -normal_y
tangent_y = normal_x
# Hebelarme r_A, r_B: vom jeweiligen Schwerpunkt zum Kontaktpunkt.
point = contact.point
offset_a_x = point.x - body_a.position.x
offset_a_y = point.y - body_a.position.y
offset_b_x = point.x - body_b.position.x
offset_b_y = point.y - body_b.position.y
constraint = _Constraint()
constraint.contact = contact
constraint.index_a = index_a
constraint.index_b = index_b
constraint.normal_x = normal_x
constraint.normal_y = normal_y
constraint.tangent_x = tangent_x
constraint.tangent_y = tangent_y
constraint.offset_a_x = offset_a_x
constraint.offset_a_y = offset_a_y
constraint.offset_b_x = offset_b_x
constraint.offset_b_y = offset_b_y
constraint.inv_mass_a = body_a.inv_mass
constraint.inv_mass_b = body_b.inv_mass
constraint.inv_inertia_a = body_a.inv_inertia
constraint.inv_inertia_b = body_b.inv_inertia
# Effektive Massen 1/K (siehe Moduldokumentation). Für die Normale entfallen die
# Drehterme, weil in dieser Engine stets r × n = 0 oder 1/I = 0 gilt.
inv_mass_sum = body_a.inv_mass + body_b.inv_mass
constraint.normal_mass = 1.0 / inv_mass_sum if inv_mass_sum > 0.0 else 0.0
arm_a = offset_a_x * tangent_y - offset_a_y * tangent_x # r_A × t
arm_b = offset_b_x * tangent_y - offset_b_y * tangent_x # r_B × t
k_tangent = (
inv_mass_sum + body_a.inv_inertia * arm_a * arm_a + body_b.inv_inertia * arm_b * arm_b
)
constraint.tangent_mass = 1.0 / k_tangent if k_tangent > 0.0 else 0.0
# Rollwiderstand (siehe Moduldokumentation): nur, wenn sich mindestens ein Körper
# drehen kann. Als Radius dient der längere Hebelarm eines drehbaren Körpers.
inv_inertia_sum = body_a.inv_inertia + body_b.inv_inertia
if inv_inertia_sum > 0.0:
lever = max(
math.hypot(offset_a_x, offset_a_y) if body_a.can_rotate else 0.0,
math.hypot(offset_b_x, offset_b_y) if body_b.can_rotate else 0.0,
)
constraint.rolling_mass = 1.0 / inv_inertia_sum
constraint.rolling_limit = self._config.rolling_resistance * lever
else:
constraint.rolling_mass = 0.0
constraint.rolling_limit = 0.0
# Materialwerte mischen: Die Reibung als geometrisches Mittel (ein glatter Partner
# macht den Kontakt glatt), die Restitution als Maximum (ein Gummiball springt auch
# von einem Holzklotz ab).
constraint.friction = math.sqrt(body_a.material.friction * body_b.material.friction)
restitution = max(body_a.material.restitution, body_b.material.restitution)
# Abprallen nach Newton: v_n' = −e·v_n. Die Aufprallgeschwindigkeit v_n stammt vom
# Beginn des Schritts – vor Schwerkraft und Warm-Start. Mit der Geschwindigkeit nach
# der Schwerkraft prallte ein Ball mit e·(v + g·dt) statt e·v ab und gewänne bei
# e = 1 mit jedem Aufprall Höhe (Energie aus dem Nichts).
velocity_a = body_a.previous_velocity
velocity_b = body_b.previous_velocity
normal_speed = (velocity_b.x - velocity_a.x) * normal_x + (
velocity_b.y - velocity_a.y
) * normal_y
# Kein Abprallen, (1) unterhalb der Schwelle: Aufliegende Körper würden sonst durch
# winzige Dauer-Hüpfer zittern; (2) wenn ein Körper gehalten wird: Die Hand fängt
# den Stoß ab. Sonst prallte ein gegen ein Hindernis gedrückter Körper in jedem
# Schritt ab und würde gleich wieder hingezogen – er zitterte.
bounces = normal_speed < -self._config.restitution_threshold
if bounces and not (body_a.held or body_b.held):
constraint.velocity_bias = -restitution * normal_speed
else:
constraint.velocity_bias = 0.0
constraint.normal_impulse = 0.0
constraint.tangent_impulse = 0.0
constraint.rolling_impulse = 0.0
constraint.penetration = contact.penetration
return constraint
def _warm_start(self) -> None:
"""Wendet die Impulse des letzten Schritts erneut an (bessere Startwerte)."""
cache = self._impulse_cache
min_alignment = self._config.warm_start_min_alignment
velocity_x = self._velocity_x
velocity_y = self._velocity_y
angular = self._angular_velocity
for c in self._constraints:
cached = cache.get(c.contact.key)
if cached is None:
continue
normal_impulse, tangent_impulse, rolling_impulse, old_normal_x, old_normal_y = cached
if old_normal_x * c.normal_x + old_normal_y * c.normal_y < min_alignment:
continue # Normale hat sich zu stark gedreht, der alte Impuls passt nicht.
c.normal_impulse = normal_impulse
c.tangent_impulse = tangent_impulse
c.rolling_impulse = rolling_impulse
impulse_x = normal_impulse * c.normal_x + tangent_impulse * c.tangent_x
impulse_y = normal_impulse * c.normal_y + tangent_impulse * c.tangent_y
a = c.index_a
b = c.index_b
velocity_x[a] -= impulse_x * c.inv_mass_a
velocity_y[a] -= impulse_y * c.inv_mass_a
angular[a] -= c.inv_inertia_a * (
c.offset_a_x * impulse_y - c.offset_a_y * impulse_x + rolling_impulse
)
velocity_x[b] += impulse_x * c.inv_mass_b
velocity_y[b] += impulse_y * c.inv_mass_b
angular[b] += c.inv_inertia_b * (
c.offset_b_x * impulse_y - c.offset_b_y * impulse_x + rolling_impulse
)
def _solve_velocity_iteration(self) -> None:
"""Ein Gauß-Seidel-Durchlauf über alle Kontakte.
Reihenfolge je Kontakt: Normale, dann Reibung, dann Rollwiderstand. Reibung und
Rollwiderstand sind durch den Normalimpuls begrenzt; werden sie danach gelöst, gilt
ihre Grenze nach jedem Durchlauf exakt für den aktuellen Normalimpuls. (Andersherum
könnte der Normalimpuls hinterher noch sinken und die Reibung über der
Coulomb-Grenze zurücklassen.)
"""
velocity_x = self._velocity_x
velocity_y = self._velocity_y
angular = self._angular_velocity
for c in self._constraints:
a = c.index_a
b = c.index_b
# --- Normale: Drehterme entfallen hier (siehe Moduldokumentation).
normal_speed = (velocity_x[b] - velocity_x[a]) * c.normal_x + (
velocity_y[b] - velocity_y[a]
) * c.normal_y
# λ = (Ziel − v_n)/K; Ziel ist die Abprallgeschwindigkeit (oder 0).
old_impulse = c.normal_impulse
new_impulse = old_impulse + (c.velocity_bias - normal_speed) * c.normal_mass
if new_impulse < 0.0:
new_impulse = 0.0 # Kontakte können nur drücken, nie ziehen.
c.normal_impulse = new_impulse
delta = new_impulse - old_impulse
impulse_x = delta * c.normal_x
impulse_y = delta * c.normal_y
velocity_x[a] -= impulse_x * c.inv_mass_a
velocity_y[a] -= impulse_y * c.inv_mass_a
velocity_x[b] += impulse_x * c.inv_mass_b
velocity_y[b] += impulse_y * c.inv_mass_b
# --- Reibung: v_rel = (v_B + ω_B × r_B) − (v_A + ω_A × r_A), ω × r = (−ω·r.y, ω·r.x)
rel_x = (velocity_x[b] - angular[b] * c.offset_b_y) - (
velocity_x[a] - angular[a] * c.offset_a_y
)
rel_y = (velocity_y[b] + angular[b] * c.offset_b_x) - (
velocity_y[a] + angular[a] * c.offset_a_x
)
tangent_speed = rel_x * c.tangent_x + rel_y * c.tangent_y
# λ so, dass das Gleiten verschwindet; danach Coulomb-Grenze auf den Gesamtimpuls.
max_friction = c.friction * c.normal_impulse
old_impulse = c.tangent_impulse
new_impulse = old_impulse - tangent_speed * c.tangent_mass
if new_impulse > max_friction:
new_impulse = max_friction
elif new_impulse < -max_friction:
new_impulse = -max_friction
c.tangent_impulse = new_impulse
delta = new_impulse - old_impulse
impulse_x = delta * c.tangent_x
impulse_y = delta * c.tangent_y
velocity_x[a] -= impulse_x * c.inv_mass_a
velocity_y[a] -= impulse_y * c.inv_mass_a
angular[a] -= c.inv_inertia_a * (c.offset_a_x * impulse_y - c.offset_a_y * impulse_x)
velocity_x[b] += impulse_x * c.inv_mass_b
velocity_y[b] += impulse_y * c.inv_mass_b
angular[b] += c.inv_inertia_b * (c.offset_b_x * impulse_y - c.offset_b_y * impulse_x)
# --- Rollwiderstand: bremst die relative Drehung, höchstens μ_r·P_n·r.
if c.rolling_mass > 0.0:
max_rolling = c.rolling_limit * c.normal_impulse
old_impulse = c.rolling_impulse
new_impulse = old_impulse - (angular[b] - angular[a]) * c.rolling_mass
if new_impulse > max_rolling:
new_impulse = max_rolling
elif new_impulse < -max_rolling:
new_impulse = -max_rolling
c.rolling_impulse = new_impulse
delta = new_impulse - old_impulse
angular[a] -= c.inv_inertia_a * delta
angular[b] += c.inv_inertia_b * delta
def _store_velocities(self) -> None:
"""Überträgt die Ergebnisse auf Körper und Kontakte und merkt sich die Impulse."""
for index, body in enumerate(self._bodies):
if body.is_static:
continue
body.velocity = Vector2(self._velocity_x[index], self._velocity_y[index])
body.angular_velocity = self._angular_velocity[index]
cache: dict[tuple[int, int], _CachedImpulses] = {}
for c in self._constraints:
contact = c.contact
contact.normal_impulse = c.normal_impulse
contact.tangent_impulse = c.tangent_impulse
cache[contact.key] = (
c.normal_impulse,
c.tangent_impulse,
c.rolling_impulse,
c.normal_x,
c.normal_y,
)
self._impulse_cache = cache
# ------------------------------------------------------------------ Positionen
def correct_positions(self) -> None:
"""Baut verbliebene Durchdringungen der zuletzt gelösten Kontakte ab.
Warum überhaupt? Impulse korrigieren nur Geschwindigkeiten. Durch den diskreten
Zeitschritt überlappen Körper trotzdem ein wenig (ein fallender Körper "landet"
meist schon etwas in der Unterlage), und ohne Korrektur würden Stapel langsam
ineinander einsinken.
Warum über Positionen statt über Zusatzgeschwindigkeit? Eine Geschwindigkeit, die
Körper auseinandertreibt, bleibt nach der Trennung erhalten und schleudert sie
davon – besonders bei großen Überlappungen, etwa wenn ein Objekt mitten in einen
Haufen gesetzt wird. Die direkte Verschiebung ("Projektion") erzeugt dagegen keine
Bewegungsenergie.
Verschoben wird im Verhältnis der inversen Massen – der leichtere Körper weicht
weiter aus, eine Wand gar nicht. Die kleine Toleranz ``penetration_slop`` bleibt
stehen, damit der Kontakt im nächsten Schritt sicher wieder erkannt wird.
"""
constraints = self._constraints
if not constraints:
return
config = self._config
slop = config.penetration_slop
factor = config.position_correction_factor
max_correction = config.max_position_correction
bodies = self._bodies
position_x = [body.position.x for body in bodies]
position_y = [body.position.y for body in bodies]
start_x = self._start_x
start_y = self._start_y
for _ in range(config.position_iterations):
for c in constraints:
a = c.index_a
b = c.index_b
# Aktuelle Tiefe = Tiefe bei der Erkennung minus die seither erfolgte
# Relativbewegung entlang der Normalen. Für Wände und Rechtecke ist das exakt
# (keine Drehung), für Kreise eine sehr gute Näherung – und viel billiger als
# eine erneute Kollisionserkennung.
moved_x = (position_x[b] - start_x[b]) - (position_x[a] - start_x[a])
moved_y = (position_y[b] - start_y[b]) - (position_y[a] - start_y[a])
penetration = c.penetration - (moved_x * c.normal_x + moved_y * c.normal_y)
correction = (penetration - slop) * factor
if correction <= 0.0:
continue
correction = min(correction, max_correction)
# Aufteilen im Verhältnis der inversen Massen (Summe > 0, da nie zwei
# statische Körper einen Kontakt bilden).
share = correction / (c.inv_mass_a + c.inv_mass_b)
shift_a = share * c.inv_mass_a
shift_b = share * c.inv_mass_b
position_x[a] -= c.normal_x * shift_a
position_y[a] -= c.normal_y * shift_a
position_x[b] += c.normal_x * shift_b
position_y[b] += c.normal_y * shift_b
for index, body in enumerate(bodies):
if body.is_static:
continue
new_x = position_x[index]
new_y = position_y[index]
if new_x != body.position.x or new_y != body.position.y:
body.position = Vector2(new_x, new_y)
+69
View File
@@ -0,0 +1,69 @@
"""Fester Zeitschritt, entkoppelt von der Bildrate ("Fixed Timestep").
Warum: Die Stabilität der Integration hängt vom Zeitschritt ab. Würde die Physik einfach
die (schwankende) Frame-Dauer als dt verwenden, verhielte sich die Simulation auf
schnellen und langsamen Rechnern unterschiedlich, und ein einzelner Ruckler (z. B. 200 ms
beim Verschieben des Fensters) ließe Körper durcheinander hindurchspringen.
Stattdessen sammelt ein Akkumulator die real vergangene Zeit und gibt sie in festen
Portionen ``dt`` an die Physik weiter. Der Rest bleibt für den nächsten Frame liegen; sein
Anteil an dt (``alpha``) erlaubt es, beim Zeichnen zwischen den letzten beiden
Physikzuständen zu interpolieren, damit die Bewegung trotzdem flüssig aussieht.
"""
from __future__ import annotations
import math
from sandbox.config import NUMERIC_EPSILON
class FixedTimestep:
"""Akkumulator, der Frame-Zeiten in eine Anzahl fester Physikschritte übersetzt.
Args:
step: Fester Zeitschritt in s.
max_steps_per_frame: Obergrenze der Schritte pro Frame. Schafft der Rechner die
Physik nicht in Echtzeit, würde der Rückstand sonst immer weiter wachsen
("Spiral of Death"). Mit Obergrenze läuft die Simulation dann nur langsamer.
"""
def __init__(self, step: float, max_steps_per_frame: int) -> None:
if not step > 0.0:
raise ValueError(f"Der Zeitschritt muss positiv sein: {step}")
if max_steps_per_frame < 1:
raise ValueError("Es muss mindestens ein Schritt pro Frame erlaubt sein")
self.step = step
self.max_steps_per_frame = max_steps_per_frame
self._accumulator = 0.0
@property
def alpha(self) -> float:
"""Anteil des angebrochenen Schritts in [0, 1) – Gewicht für die Interpolation."""
return self._accumulator / self.step
def advance(self, frame_time: float) -> int:
"""Nimmt die Dauer eines Frames entgegen und liefert die Zahl fälliger Schritte.
Args:
frame_time: Real vergangene Zeit seit dem letzten Aufruf in s.
Returns:
Anzahl der jetzt auszuführenden Physikschritte.
"""
if frame_time < 0.0:
raise ValueError(f"Die Frame-Zeit darf nicht negativ sein: {frame_time}")
self._accumulator += frame_time
# Das kleine Epsilon fängt Rundungsfehler ab: 2·(1/120) ergibt als Gleitkommazahl
# nicht exakt 1/60, und ohne Toleranz ginge gelegentlich ein Schritt "verloren".
steps = math.floor(self._accumulator / self.step + NUMERIC_EPSILON)
if steps > self.max_steps_per_frame:
steps = self.max_steps_per_frame
self._accumulator = 0.0 # Rückstand verwerfen statt ihn endlos mitzuschleppen.
else:
self._accumulator = max(0.0, self._accumulator - steps * self.step)
return steps
def reset(self) -> None:
"""Verwirft angesammelte Zeit (z. B. beim Pausieren)."""
self._accumulator = 0.0
+162
View File
@@ -0,0 +1,162 @@
"""Eigene 2D-Vektorklasse der Physik-Schicht.
Die Physik nutzt bewusst weder ``pygame.Vector2`` noch externe Mathematik-Bibliotheken,
sondern nur diese Klasse und das Standardmodul :mod:`math`. Dadurch hängt die Physik nicht
von einer Grafikbibliothek ab und lässt sich isoliert testen.
"""
from __future__ import annotations
import math
from typing import Final
from sandbox.config import NUMERIC_EPSILON
# ``object.__setattr__`` einmal nachschlagen statt bei jedem Konstruktoraufruf: Vektoren
# entstehen tausendfach pro Simulationsschritt, und das normale Setzen ist gesperrt (s. u.).
_set_slot = object.__setattr__
class Vector2:
"""Unveränderlicher zweidimensionaler Vektor.
Jede Operation liefert einen neuen Vektor, kein Operand wird verändert. Vektoren
verhalten sich damit wie Zahlen: Ein Körper kann seine Position herausgeben, ohne dass
jemand sie über die geteilte Referenz versehentlich mitverändert. Zuweisungen wie
``v.x = 1.0`` lösen deshalb einen :class:`AttributeError` aus.
Attributes:
x: x-Komponente.
y: y-Komponente (zeigt wie auf dem Bildschirm nach unten).
"""
__slots__ = ("x", "y")
x: float
y: float
def __init__(self, x: float = 0.0, y: float = 0.0) -> None:
_set_slot(self, "x", x)
_set_slot(self, "y", y)
# ------------------------------------------------------------------ Unveränderlichkeit
def __setattr__(self, name: str, value: object) -> None:
raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gesetzt werden)")
def __delattr__(self, name: str) -> None:
raise AttributeError(f"Vector2 ist unveränderlich ('{name}' kann nicht gelöscht werden)")
def __reduce__(self) -> tuple[type[Vector2], tuple[float, float]]:
# copy und pickle würden die Slots sonst per setattr befüllen – das ist gesperrt.
return (Vector2, (self.x, self.y))
# ------------------------------------------------------------------ Arithmetik
def __add__(self, other: Vector2) -> Vector2:
return Vector2(self.x + other.x, self.y + other.y)
def __sub__(self, other: Vector2) -> Vector2:
return Vector2(self.x - other.x, self.y - other.y)
def __mul__(self, scalar: float) -> Vector2:
return Vector2(self.x * scalar, self.y * scalar)
def __rmul__(self, scalar: float) -> Vector2:
return Vector2(self.x * scalar, self.y * scalar)
def __truediv__(self, scalar: float) -> Vector2:
return Vector2(self.x / scalar, self.y / scalar)
def __neg__(self) -> Vector2:
return Vector2(-self.x, -self.y)
def __eq__(self, other: object) -> bool:
if not isinstance(other, Vector2):
return NotImplemented
return self.x == other.x and self.y == other.y
def __hash__(self) -> int:
return hash((self.x, self.y))
def __repr__(self) -> str:
return f"Vector2({self.x!r}, {self.y!r})"
# ------------------------------------------------------------------ Vektoroperationen
def dot(self, other: Vector2) -> float:
"""Skalarprodukt ``a·b = a.x·b.x + a.y·b.y``.
Liefert die Länge der Projektion von ``other`` auf diesen Vektor (mal dessen Länge).
Mit einer Einheitsnormalen n misst ``v.dot(n)`` also, wie schnell sich etwas
entlang n bewegt.
"""
return self.x * other.x + self.y * other.y
def cross(self, other: Vector2) -> float:
"""z-Komponente des Kreuzprodukts ``a × b = a.x·b.y − a.y·b.x``.
In 2D ist das Kreuzprodukt ein Skalar. Physikalisch ist ``r × F`` das Drehmoment,
das eine Kraft (bzw. ein Impuls) F am Hebelarm r erzeugt.
"""
return self.x * other.y - self.y * other.x
def length(self) -> float:
"""Euklidische Länge (Betrag) des Vektors."""
return math.hypot(self.x, self.y)
def length_squared(self) -> float:
"""Quadrat der Länge – spart die Wurzel, wenn nur Längen verglichen werden."""
return self.x * self.x + self.y * self.y
def normalized(self) -> Vector2:
"""Einheitsvektor gleicher Richtung.
Der Nullvektor hat keine Richtung. Statt eine Division durch 0 (und damit NaN-Werte,
die sich durch die ganze Simulation fressen würden) zu riskieren, wird dann der
Nullvektor zurückgegeben; der Aufrufer entscheidet über eine Ersatzrichtung.
"""
length = math.hypot(self.x, self.y)
if length <= NUMERIC_EPSILON:
return Vector2(0.0, 0.0)
return Vector2(self.x / length, self.y / length)
def perpendicular(self) -> Vector2:
"""Um +90° gedrehter Vektor ``(−y, x)``.
Nützlich für Tangenten und für ``ω × r``: Die Bahngeschwindigkeit eines Punktes im
Abstand r eines mit ω drehenden Körpers ist ``ω · r.perpendicular()``.
"""
return Vector2(-self.y, self.x)
def distance_to(self, other: Vector2) -> float:
"""Abstand zwischen zwei Punkten."""
return math.hypot(other.x - self.x, other.y - self.y)
def clamped(self, max_length: float) -> Vector2:
"""Vektor gleicher Richtung, dessen Länge höchstens ``max_length`` beträgt."""
length_sq = self.x * self.x + self.y * self.y
if length_sq <= max_length * max_length:
return self
scale = max_length / math.sqrt(length_sq)
return Vector2(self.x * scale, self.y * scale)
def lerp(self, other: Vector2, t: float) -> Vector2:
"""Lineare Interpolation: ``t = 0`` liefert ``self``, ``t = 1`` liefert ``other``."""
return Vector2(self.x + (other.x - self.x) * t, self.y + (other.y - self.y) * t)
def is_finite(self) -> bool:
"""True, wenn keine Komponente NaN oder unendlich ist."""
return math.isfinite(self.x) and math.isfinite(self.y)
def is_close(self, other: Vector2, tolerance: float = NUMERIC_EPSILON) -> bool:
"""Komponentenweiser Vergleich mit absoluter Toleranz."""
return abs(self.x - other.x) <= tolerance and abs(self.y - other.y) <= tolerance
def to_tuple(self) -> tuple[float, float]:
"""Komponenten als Tupel ``(x, y)``, z. B. für Zeichenfunktionen."""
return (self.x, self.y)
#: Der Nullvektor. Da Vektoren unveränderlich sind, darf er überall geteilt werden.
ZERO: Final = Vector2(0.0, 0.0)
+280
View File
@@ -0,0 +1,280 @@
"""Die Physikwelt: verwaltet Körper und Wände und führt Simulationsschritte aus."""
from __future__ import annotations
import math
from sandbox.config import PhysicsConfig
from sandbox.physics.body import Body, Material
from sandbox.physics.broadphase import find_candidate_pairs
from sandbox.physics.collision import Contact, collide
from sandbox.physics.sleep import update_sleep, wake_touched_bodies
from sandbox.physics.solver import ContactSolver
from sandbox.physics.vector import ZERO, Vector2
class World:
"""Rechteckige Welt mit Wänden an allen vier Seiten.
Koordinaten: Ursprung oben links, x nach rechts, y nach unten, Einheit px. Die Welt
kennt keinen Bildschirm – wo sie gezeichnet wird, entscheidet die Anwendung.
Ein Aufruf von :meth:`step` rückt die Simulation um einen festen Zeitschritt vor
(semi-impliziter Euler mit Kontaktauflösung, Details in :meth:`step`).
Args:
width: Breite der Welt in px.
height: Höhe der Welt in px.
config: Physikparameter.
Attributes:
width: Breite der Welt in px.
height: Höhe der Welt in px.
config: Physikparameter.
bodies: Alle dynamischen Körper in Einfügereihenfolge (später eingefügte liegen beim
Zeichnen oben).
walls: Die vier statischen Wände (links, rechts, oben, unten).
contacts: Die Kontakte des letzten Schritts (für die Debug-Anzeige).
step_count: Anzahl bisher ausgeführter Schritte.
"""
def __init__(self, width: float, height: float, config: PhysicsConfig) -> None:
if not (width > 0.0 and height > 0.0):
raise ValueError(f"Die Welt braucht eine positive Größe: {width} x {height}")
self.width = width
self.height = height
self.config = config
self.bodies: list[Body] = []
self.contacts: list[Contact] = []
self.step_count = 0
self._gravity_enabled = True
self._solver = ContactSolver(config)
wall_material = Material(
density=0.0, restitution=config.wall_restitution, friction=config.wall_friction
)
# Jede Wandnormale zeigt in die Welt hinein; frei ist die Seite normal·p ≥ offset.
self.walls: list[Body] = [
Body.wall(Vector2(1.0, 0.0), 0.0, wall_material), # links: x ≥ 0
Body.wall(Vector2(-1.0, 0.0), -width, wall_material), # rechts: x ≤ width
Body.wall(Vector2(0.0, 1.0), 0.0, wall_material), # oben: y ≥ 0
Body.wall(Vector2(0.0, -1.0), -height, wall_material), # unten: y ≤ height
]
# ------------------------------------------------------------------ Schwerkraft
@property
def gravity_enabled(self) -> bool:
"""Ob die Schwerkraft wirkt."""
return self._gravity_enabled
@gravity_enabled.setter
def gravity_enabled(self, enabled: bool) -> None:
if enabled != self._gravity_enabled:
self._gravity_enabled = enabled
# Schlafende Körper würden die Änderung sonst nicht bemerken und z. B. ohne
# Schwerkraft am Boden kleben bleiben bzw. mit Schwerkraft in der Luft hängen.
self.wake_all()
@property
def gravity(self) -> Vector2:
"""Aktuell wirkende Schwerkraftbeschleunigung (Nullvektor, wenn abgeschaltet)."""
if not self._gravity_enabled:
return ZERO
return Vector2(*self.config.gravity)
# ------------------------------------------------------------------ Körperverwaltung
def add_body(self, body: Body) -> Body:
"""Fügt einen dynamischen Körper hinzu und gibt ihn zurück."""
if body.is_static:
raise ValueError("Statische Körper werden von der Welt selbst verwaltet (Wände)")
self.bodies.append(body)
return body
def remove_body(self, body: Body) -> None:
"""Entfernt einen Körper und weckt alles, was auf ihm gelegen haben könnte.
Dafür genügt es, seine Schlaf-Insel zu wecken: Schlafende Körper, die einander
berühren, sind immer gemeinsam eingeschlafen und gehören damit zur selben Insel.
"""
self.bodies.remove(body)
body.wake()
def clear(self) -> None:
"""Entfernt alle Körper."""
self.bodies.clear()
self.contacts = []
self._solver.reset()
def wake_all(self) -> None:
"""Weckt alle Körper auf."""
for body in self.bodies:
body.wake()
def body_at(self, point: Vector2, tolerance: float = 0.0) -> Body | None:
"""Oberster Körper an einem Punkt (später eingefügte Körper liegen oben).
Args:
point: Punkt in Weltkoordinaten.
tolerance: Vergrößert die Körper beim Test, damit kleine Objekte leichter zu
treffen sind.
"""
for body in reversed(self.bodies):
if body.contains_point(point, tolerance):
return body
return None
def clamp_inside(self, position: Vector2, half_extents: tuple[float, float]) -> Vector2:
"""Verschiebt eine Mittelpunktsposition so, dass ein Körper der Größe ganz hineinpasst."""
half_x, half_y = half_extents
x = min(max(position.x, half_x), self.width - half_x)
y = min(max(position.y, half_y), self.height - half_y)
if x == position.x and y == position.y:
return position
return Vector2(x, y)
# ------------------------------------------------------------------ Simulation
def step(self, dt: float) -> None:
"""Rückt die Simulation um ``dt`` Sekunden vor.
Ablauf (semi-impliziter Euler mit Kontaktauflösung):
1. Zustand merken: Position und Winkel für die Zeichen-Interpolation, die
Geschwindigkeit für die Aufprallgeschwindigkeit beim Abprallen.
2. Geschwindigkeiten integrieren: ``v += g·dt`` (plus Dämpfung).
3. Kontakte suchen: Sweep-and-Prune, dann genaue Tests; schlafende Körper, die
berührt werden, samt ihrer Schlaf-Insel wecken; danach Wandkontakte der wachen
Körper.
4. Impulse lösen (Restitution, Reibung, Rollwiderstand) und Geschwindigkeiten
begrenzen.
5. Positionen integrieren: ``x += v·dt`` – mit der *neuen* Geschwindigkeit. Genau
das macht den Euler "semi-implizit" (symplektisch): Anders als beim expliziten
Euler wächst die Energie dabei nicht systematisch an, Umlaufbahnen und
Schwingungen bleiben stabil.
6. Verbliebene Durchdringungen über die Positionen abbauen.
7. Notbremse: kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand.
8. Schlafmodus aktualisieren.
"""
awake = [body for body in self.bodies if not body.sleeping]
self._remember_state(awake)
self._integrate_velocities(awake, dt)
contacts = self._find_body_contacts()
woken = wake_touched_bodies(contacts)
self._remember_state(woken)
awake.extend(woken)
contacts.extend(self._find_wall_contacts(awake))
self._solver.solve_velocities(contacts)
self._clamp_velocities(awake)
self._integrate_positions(awake, dt)
self._solver.correct_positions()
self._enforce_bounds(awake)
if self.config.sleep_enabled:
update_sleep(awake, contacts, dt, self.config)
self.contacts = contacts
self.step_count += 1
@staticmethod
def _remember_state(bodies: list[Body]) -> None:
"""Merkt sich den Zustand zu Beginn des Schritts (siehe ``Body.previous_*``)."""
for body in bodies:
body.previous_position = body.position
body.previous_angle = body.angle
body.previous_velocity = body.velocity
def _integrate_velocities(self, bodies: list[Body], dt: float) -> None:
"""Wendet Schwerkraft und Dämpfung auf die Geschwindigkeiten an."""
gravity = self.gravity
gravity_dx = gravity.x * dt
gravity_dy = gravity.y * dt
# Dämpfung als v ← v / (1 + c·dt): Näherung von v·e^(−c·dt), die anders als
# v·(1 − c·dt) auch bei großem c·dt nie das Vorzeichen umkehrt.
linear_factor = 1.0 / (1.0 + dt * self.config.linear_damping)
angular_factor = 1.0 / (1.0 + dt * self.config.angular_damping)
for body in bodies:
if body.held:
continue # Die Geschwindigkeit gehaltener Körper gibt die Maus vor.
velocity = body.velocity
body.velocity = Vector2(
(velocity.x + gravity_dx) * linear_factor,
(velocity.y + gravity_dy) * linear_factor,
)
body.angular_velocity *= angular_factor
def _find_body_contacts(self) -> list[Contact]:
"""Kontakte zwischen dynamischen Körpern (grobe Vorauswahl, dann genauer Test)."""
contacts: list[Contact] = []
for body_a, body_b in find_candidate_pairs(self.bodies):
contact = collide(body_a, body_b)
if contact is not None:
contacts.append(contact)
return contacts
def _find_wall_contacts(self, bodies: list[Body]) -> list[Contact]:
"""Kontakte der wachen Körper mit den Wänden."""
left, right, top, bottom = self.walls
width = self.width
height = self.height
contacts: list[Contact] = []
for body in bodies:
min_x, min_y, max_x, max_y = body.bounds()
# Billiger Vortest über die umschließende Box, genauer Test nur bei Bedarf.
for touches, wall in (
(min_x <= 0.0, left),
(max_x >= width, right),
(min_y <= 0.0, top),
(max_y >= height, bottom),
):
if touches:
contact = collide(body, wall)
if contact is not None:
contacts.append(contact)
return contacts
def _clamp_velocities(self, bodies: list[Body]) -> None:
"""Begrenzt Geschwindigkeit und Drehung (Schutz vor Tunneln und Explosionen)."""
max_speed = self.config.max_speed
max_speed_sq = max_speed * max_speed
max_angular = self.config.max_angular_speed
for body in bodies:
velocity = body.velocity
speed_sq = velocity.length_squared()
if speed_sq > max_speed_sq:
body.velocity = velocity * (max_speed / math.sqrt(speed_sq))
if abs(body.angular_velocity) > max_angular:
body.angular_velocity = math.copysign(max_angular, body.angular_velocity)
def _integrate_positions(self, bodies: list[Body], dt: float) -> None:
"""``x += v·dt`` und ``θ += ω·dt`` mit den bereits aktualisierten Geschwindigkeiten."""
full_turn = math.tau
for body in bodies:
velocity = body.velocity
position = body.position
body.position = Vector2(position.x + velocity.x * dt, position.y + velocity.y * dt)
if body.angular_velocity != 0.0:
# Winkel im Bereich [−π, π] halten, damit er über lange Zeit nicht an
# Genauigkeit verliert.
body.angle = math.remainder(body.angle + body.angular_velocity * dt, full_turn)
def _enforce_bounds(self, bodies: list[Body]) -> None:
"""Notbremse: Kein Körper ragt weiter als ``max_wall_penetration`` in eine Wand.
Im Normalfall halten die Wandkontakte die Körper in der Welt. Die Grenze liegt über
der größten Eindringtiefe eines frei fliegenden Körpers pro Schritt und greift daher
nur in Extremfällen, etwa wenn ein Körper von einem gezogenen Objekt gegen die Wand
gequetscht wird. Geschwindigkeiten bleiben unangetastet: Den eigentlichen Stoß mit
der Wand löst im nächsten Schritt der Solver.
"""
allowed = self.config.max_wall_penetration
for body in bodies:
half_x, half_y = body.half_extents()
position = body.position
x = min(max(position.x, half_x - allowed), self.width - half_x + allowed)
y = min(max(position.y, half_y - allowed), self.height - half_y + allowed)
if x != position.x or y != position.y:
body.position = Vector2(x, y)
+243
View File
@@ -0,0 +1,243 @@
"""Zeichnet die Physikwelt: Hintergrund, Körper, Hervorhebungen, Vorschau und Debug-Ansicht."""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import pygame
from sandbox.config import Color
from sandbox.interaction import BodyStyle, ShapeKind
from sandbox.physics import ZERO, Body, Circle, Vector2, World
from sandbox.ui.drawing import draw_box, draw_circle
from sandbox.ui.style import UiStyle, shade
from sandbox.viewport import Viewport
@dataclass(frozen=True)
class SpawnPreview:
"""Halbtransparente Vorschau des Objekts, das ein Klick erzeugen würde.
Attributes:
shape: Form des Objekts.
half_extents: Halbe Breite und Höhe in px.
color: Farbe des Objekts.
screen_center: Mittelpunkt auf dem Bildschirm (kann über der Sidebar liegen,
wenn ein Objekt gerade aus der Vorschau-Kachel gezogen wird).
"""
shape: ShapeKind
half_extents: tuple[float, float]
color: Color
screen_center: tuple[float, float]
@dataclass
class WorldOverlay:
"""Zustand der Bedienung, der über der Welt angezeigt wird.
Attributes:
hovered: Körper unter der Maus.
held: Gezogener Körper.
throw_velocity: Aktuelle Wurfgeschwindigkeit des gezogenen Körpers.
paused: Ob die Simulation pausiert ist.
debug: Ob die Debug-Ansicht (Kontakte, Schlafzustand) aktiv ist.
debug_lines: Textzeilen der Debug-Ansicht.
"""
hovered: Body | None = None
held: Body | None = None
throw_velocity: Vector2 = ZERO
paused: bool = False
debug: bool = False
debug_lines: list[str] = field(default_factory=list)
def interpolate_angle(previous: float, current: float, alpha: float) -> float:
"""Interpoliert einen Winkel auf dem kürzeren Weg.
Der Winkel wird in der Physik auf [−π, π] gehalten. Springt er von +3,1 auf −3,1, hat
sich der Körper nur um 0,08 rad gedreht – naive Interpolation würde ihn für einen Frame
fast eine ganze Umdrehung zurückdrehen lassen.
"""
return previous + math.remainder(current - previous, math.tau) * alpha
class Renderer:
"""Zeichnet die Welt in ihren Bildschirmbereich.
Args:
screen: Zielfläche (das Fenster).
viewport: Lage der Welt auf dem Bildschirm.
style: Farben, Schriften, Maße.
"""
def __init__(self, screen: pygame.Surface, viewport: Viewport, style: UiStyle) -> None:
self.screen = screen
self.viewport = viewport
self.style = style
self._background = self._render_background()
def _render_background(self) -> pygame.Surface:
"""Hintergrund mit Hilfsgitter – einmal vorberechnet statt jedes Bild neu."""
theme = self.style.theme
spacing = self.style.ui.grid_spacing
width, height = self.viewport.width, self.viewport.height
surface = pygame.Surface((width, height))
surface.fill(theme.world_background)
for x in range(spacing, width, spacing):
pygame.draw.line(surface, theme.grid, (x, 0), (x, height))
for y in range(spacing, height, spacing):
pygame.draw.line(surface, theme.grid, (0, y), (width, y))
return surface
# ------------------------------------------------------------------ Welt
def draw_world(self, world: World, alpha: float, overlay: WorldOverlay) -> None:
"""Zeichnet Hintergrund, alle Körper und die Bedien-Hinweise.
Args:
world: Die Welt.
alpha: Interpolationsgewicht zwischen letztem und aktuellem Physikzustand.
overlay: Hervorhebungen und Zusatzanzeigen.
"""
screen = self.screen
area = pygame.Rect(self.viewport.rect)
screen.blit(self._background, area.topleft)
screen.set_clip(area) # Körper, die leicht in eine Wand ragen, nicht über die Sidebar
for body in world.bodies:
self._draw_body(body, alpha, overlay)
if overlay.held is not None:
self._draw_throw_arrow(overlay.held, overlay.throw_velocity)
if overlay.debug:
self._draw_debug(world, overlay.debug_lines)
screen.set_clip(None)
pygame.draw.rect(screen, self.style.theme.world_border, area, self.style.ui.border_width)
if overlay.paused:
self._draw_pause_badge()
def _draw_body(self, body: Body, alpha: float, overlay: WorldOverlay) -> None:
theme = self.style.theme
ui = self.style.ui
# Zwischen zwei Physikschritten interpolieren: Die Physik läuft mit 120 Hz, der
# Bildschirm z. B. mit 60 Hz – ohne Interpolation ruckelt die Bewegung sichtbar.
position = body.previous_position.lerp(body.position, alpha)
center = self.viewport.to_screen(position)
style = body.user_data
color = style.color if isinstance(style, BodyStyle) else theme.body_palette[0]
outline = shade(color, theme.outline_shade)
if body.sleeping and overlay.debug:
outline = theme.debug_sleeping
if body is overlay.held:
highlight: Color | None = theme.held_outline
elif body is overlay.hovered:
highlight = theme.highlight
else:
highlight = None
shape = body.shape
if isinstance(shape, Circle):
angle = interpolate_angle(body.previous_angle, body.angle, alpha)
draw_circle(
self.screen,
color,
outline,
center,
shape.radius,
outline_width=ui.outline_width,
angle=angle,
marker_length=ui.marker_length,
)
if highlight is not None:
pygame.draw.aacircle(
self.screen, highlight, center, shape.radius, ui.highlight_width
)
else:
half_extents = shape.half_extents()
draw_box(
self.screen, color, outline, center, half_extents, outline_width=ui.outline_width
)
if highlight is not None:
draw_box(
self.screen,
color,
highlight,
center,
half_extents,
outline_width=ui.highlight_width,
)
def _draw_throw_arrow(self, body: Body, velocity: Vector2) -> None:
"""Linie in Wurfrichtung; ihre Länge zeigt, wie weit der Körper in kurzer Zeit flöge."""
if velocity.length_squared() == 0.0:
return
ui = self.style.ui
start = self.viewport.to_screen(body.position)
end = (
start[0] + velocity.x * ui.throw_arrow_time,
start[1] + velocity.y * ui.throw_arrow_time,
)
color = self.style.theme.throw_arrow
pygame.draw.line(self.screen, color, start, end, ui.outline_width)
pygame.draw.aacircle(self.screen, color, end, ui.debug_contact_radius)
def _draw_debug(self, world: World, lines: list[str]) -> None:
"""Kontaktpunkte, Kontaktnormalen und Kennzahlen."""
theme = self.style.theme
ui = self.style.ui
for contact in world.contacts:
point = self.viewport.to_screen(contact.point)
end = (
point[0] + contact.normal.x * ui.debug_normal_length,
point[1] + contact.normal.y * ui.debug_normal_length,
)
pygame.draw.line(self.screen, theme.debug_normal, point, end)
pygame.draw.circle(self.screen, theme.debug_contact, point, ui.debug_contact_radius)
x = self.viewport.left + ui.padding
y = self.viewport.top + ui.padding
for line in lines:
rendered = self.style.render_text(line, self.style.small_font, theme.text)
self.screen.blit(rendered, (x, y))
y += rendered.get_height()
def _draw_pause_badge(self) -> None:
theme = self.style.theme
ui = self.style.ui
text = self.style.render_text(
"PAUSE – Leertaste zum Fortsetzen", self.style.label_font, theme.text
)
badge = text.get_rect().inflate(2 * ui.padding, ui.padding)
badge.midtop = (self.viewport.left + self.viewport.width // 2, ui.padding)
pygame.draw.rect(self.screen, theme.panel_active, badge, border_radius=ui.corner_radius)
self.screen.blit(text, text.get_rect(center=badge.center))
# ------------------------------------------------------------------ Vorschau
def draw_preview(self, preview: SpawnPreview) -> None:
"""Zeichnet die halbtransparente Vorschau eines neuen Objekts."""
theme = self.style.theme
ui = self.style.ui
half_width, half_height = preview.half_extents
margin = ui.highlight_width
size = (math.ceil(2 * half_width) + 2 * margin, math.ceil(2 * half_height) + 2 * margin)
layer = pygame.Surface(size, pygame.SRCALPHA)
fill = (*preview.color, theme.preview_alpha)
outline = (*theme.highlight, theme.preview_alpha)
local_center = (size[0] / 2, size[1] / 2)
if preview.shape is ShapeKind.CIRCLE:
pygame.draw.aacircle(layer, fill, local_center, half_width)
pygame.draw.aacircle(layer, outline, local_center, half_width, ui.outline_width)
else:
draw_box(
layer,
fill,
outline,
local_center,
preview.half_extents,
outline_width=ui.outline_width,
)
self.screen.blit(
layer, (preview.screen_center[0] - size[0] / 2, preview.screen_center[1] - size[1] / 2)
)
+1
View File
@@ -0,0 +1 @@
"""Bedienoberfläche: Widgets und die Werkzeugleiste (Sidebar)."""
+65
View File
@@ -0,0 +1,65 @@
"""Zeichenhelfer für Körperformen – gemeinsam genutzt von Renderer und Vorschau-Kachel."""
from __future__ import annotations
import math
from typing import TypeAlias
import pygame
#: RGB- oder RGBA-Farbe (RGBA für halbtransparente Vorschauen).
DrawColor: TypeAlias = tuple[int, ...]
def draw_circle(
surface: pygame.Surface,
color: DrawColor,
outline: DrawColor,
center: tuple[float, float],
radius: float,
*,
outline_width: int,
angle: float | None = None,
marker_length: float = 0.0,
) -> None:
"""Zeichnet einen geglätteten Kreis mit Umrandung und optionalem Drehmarker.
Args:
surface: Zielfläche.
color: Füllfarbe.
outline: Farbe von Umrandung und Drehmarker.
center: Mittelpunkt in Bildschirmkoordinaten.
radius: Radius in px.
outline_width: Linienstärke der Umrandung.
angle: Drehwinkel in rad. Ohne Marker wäre die Drehung eines einfarbigen Kreises
unsichtbar – man sähe nicht, ob er rollt oder rutscht.
marker_length: Länge des Markers als Anteil des Radius.
"""
# Die Füllung braucht keine Kantenglättung: Die geglättete Umrandung liegt genau auf
# ihrem Rand und verdeckt die Treppenstufen. Das halbiert die teuren aacircle-Aufrufe.
pygame.draw.circle(surface, color, center, radius)
pygame.draw.aacircle(surface, outline, center, radius, outline_width)
if angle is not None:
end = (
center[0] + math.cos(angle) * radius * marker_length,
center[1] + math.sin(angle) * radius * marker_length,
)
pygame.draw.line(surface, outline, center, end, outline_width)
def draw_box(
surface: pygame.Surface,
color: DrawColor,
outline: DrawColor,
center: tuple[float, float],
half_extents: tuple[float, float],
*,
outline_width: int,
corner_radius: int = 0,
) -> None:
"""Zeichnet ein achsenparalleles Rechteck mit Umrandung."""
half_width, half_height = half_extents
rect = pygame.Rect(0, 0, round(2 * half_width), round(2 * half_height))
rect.center = (round(center[0]), round(center[1]))
pygame.draw.rect(surface, color, rect, border_radius=corner_radius)
pygame.draw.rect(surface, outline, rect, outline_width, border_radius=corner_radius)
+298
View File
@@ -0,0 +1,298 @@
"""Werkzeugleiste am linken Fensterrand. Sie gehört nicht zur Physikwelt."""
from __future__ import annotations
from collections.abc import Callable
from functools import partial
from typing import Protocol
import pygame
from sandbox.config import Color, InteractionConfig
from sandbox.interaction import ShapeKind, ToolSettings
from sandbox.ui.style import UiStyle
from sandbox.ui.widgets import (
Button,
ColorPicker,
Label,
LabelKind,
SegmentedControl,
Slider,
SpawnTile,
Widget,
)
class SidebarControls(Protocol):
"""Was die Sidebar von der Anwendung braucht (erfüllt von :class:`~sandbox.main.App`)."""
def is_paused(self) -> bool:
"""Ob die Simulation pausiert ist."""
...
def toggle_pause(self) -> None:
"""Pausiert bzw. setzt fort."""
...
def gravity_enabled(self) -> bool:
"""Ob die Schwerkraft wirkt."""
...
def toggle_gravity(self) -> None:
"""Schaltet die Schwerkraft um."""
...
def clear_world(self) -> None:
"""Entfernt alle Körper."""
...
def begin_spawn_drag(self) -> None:
"""Beginnt das Herausziehen eines neuen Objekts aus der Vorschau-Kachel."""
...
def preview_color(self) -> Color:
"""Farbe des nächsten Objekts."""
...
def status_lines(self) -> list[str]:
"""Statuszeilen für den unteren Rand (Objektzahl, FPS …)."""
...
def _format_size(value: float) -> str:
return f"{value:.0f} px"
def _format_ratio(value: float) -> str:
return f"{value:.2f}"
def _format_density(value: float) -> str:
return f"{value:.1f}"
class Sidebar:
"""Baut die Bedienelemente auf, verteilt Events an sie und zeichnet sie.
Args:
rect: Bereich der Sidebar auf dem Bildschirm.
style: Farben, Schriften, Maße.
tools: Die Werkzeug-Einstellungen, die die Regler verändern.
limits: Wertebereiche der Regler.
palette: Farbpalette.
controls: Anbindung an die Anwendung.
"""
def __init__(
self,
rect: pygame.Rect,
style: UiStyle,
tools: ToolSettings,
limits: InteractionConfig,
palette: tuple[Color, ...],
controls: SidebarControls,
) -> None:
self.rect = pygame.Rect(rect)
self.style = style
self.tools = tools
self.controls = controls
self.widgets: list[Widget] = []
self._status_top = 0
self._build(limits, palette)
# ------------------------------------------------------------------ Aufbau
def _build(self, limits: InteractionConfig, palette: tuple[Color, ...]) -> None:
"""Ordnet die Widgets von oben nach unten an."""
ui = self.style.ui
tools = self.tools
controls = self.controls
left = self.rect.x + ui.padding
width = self.rect.width - 2 * ui.padding
y = self.rect.y + ui.padding
def row(height: int) -> pygame.Rect:
return pygame.Rect(left, y, width, height)
def add(widget: Widget, gap: int = ui.row_gap) -> None:
nonlocal y
self.widgets.append(widget)
y = widget.rect.bottom + gap
def slider(
name: str,
label: str,
value_format: Callable[[float], str],
*,
minimum: float,
maximum: float,
step: float,
is_enabled: Callable[[], bool] | None = None,
) -> Slider:
# Der Regler liest und schreibt direkt das gleichnamige Attribut der Einstellungen.
return Slider(
row(ui.slider_height),
label,
minimum=minimum,
maximum=maximum,
step=step,
get_value=partial(getattr, tools, name),
set_value=partial(setattr, tools, name),
value_format=value_format,
knob_radius=ui.slider_knob_radius,
is_enabled=is_enabled,
name=name,
)
add(Label(row(ui.header_height + ui.padding), "Physik-Sandbox", LabelKind.TITLE))
add(Label(row(ui.header_height), "OBJEKT", LabelKind.HEADER))
add(
SegmentedControl(
row(ui.button_height),
[(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")],
partial(getattr, tools, "shape"),
partial(setattr, tools, "shape"),
name="shape",
)
)
add(
slider(
"size",
"Größe",
_format_size,
minimum=limits.min_size,
maximum=limits.max_size,
step=limits.size_step,
)
)
add(
slider(
"aspect_ratio",
"Seitenverhältnis",
_format_ratio,
minimum=limits.min_aspect_ratio,
maximum=limits.max_aspect_ratio,
step=limits.aspect_ratio_step,
is_enabled=lambda: tools.shape is ShapeKind.BOX,
),
ui.section_gap,
)
add(Label(row(ui.header_height), "MATERIAL", LabelKind.HEADER))
add(
slider(
"restitution",
"Elastizität",
_format_ratio,
minimum=0.0,
maximum=limits.max_restitution,
step=limits.restitution_step,
)
)
add(
slider(
"density",
"Dichte",
_format_density,
minimum=limits.min_density,
maximum=limits.max_density,
step=limits.density_step,
)
)
add(Label(row(ui.header_height), "FARBE", LabelKind.HEADER))
add(
ColorPicker(
row(ui.swatch_size),
palette,
partial(getattr, tools, "color_index"),
partial(setattr, tools, "color_index"),
swatch_size=ui.swatch_size,
swatch_gap=ui.swatch_gap,
name="color",
),
ui.section_gap,
)
add(
SpawnTile(
row(ui.spawn_tile_height),
lambda: tools,
controls.preview_color,
controls.begin_spawn_drag,
hint="Ziehen oder in die Welt klicken",
name="spawn_tile",
),
ui.section_gap,
)
add(Label(row(ui.header_height), "SIMULATION", LabelKind.HEADER))
half = (width - ui.row_gap) // 2
self.widgets.append(
Button(
pygame.Rect(left, y, half, ui.button_height),
lambda: "Weiter" if controls.is_paused() else "Pause",
controls.toggle_pause,
is_active=controls.is_paused,
name="pause",
)
)
add(
Button(
pygame.Rect(
left + half + ui.row_gap, y, width - half - ui.row_gap, ui.button_height
),
lambda: "Schwerkraft an" if controls.gravity_enabled() else "Schwerkraft aus",
controls.toggle_gravity,
is_active=controls.gravity_enabled,
name="gravity",
)
)
add(
Button(
row(ui.button_height),
"Alles löschen",
controls.clear_world,
danger=True,
name="clear",
),
ui.section_gap,
)
self._status_top = y
# ------------------------------------------------------------------ Laufzeit
def widget(self, name: str) -> Widget:
"""Findet ein Widget über seinen Namen."""
for widget in self.widgets:
if widget.name == name:
return widget
raise KeyError(name)
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Leitet ein Event an die Widgets weiter.
Returns:
True, wenn ein Widget das Event verbraucht hat oder ein Klick auf die Sidebar
fiel – solche Klicks dürfen nie in der Welt landen.
"""
if event.type == pygame.MOUSEMOTION:
# Alle Widgets sehen jede Mausbewegung (Hover-Zustand, gezogene Regler).
results = [widget.handle_event(event, mouse_pos) for widget in self.widgets]
return any(results)
for widget in self.widgets:
if widget.handle_event(event, mouse_pos):
return True
is_click = event.type in (pygame.MOUSEBUTTONDOWN, pygame.MOUSEBUTTONUP)
return is_click and self.rect.collidepoint(mouse_pos)
def draw(self, surface: pygame.Surface) -> None:
"""Zeichnet Hintergrund, Widgets und Statuszeilen."""
style = self.style
pygame.draw.rect(surface, style.theme.sidebar_background, self.rect)
for widget in self.widgets:
widget.draw(surface, style)
y = self._status_top
for line in self.controls.status_lines():
rendered = style.render_text(line, style.small_font, style.theme.text_dim)
surface.blit(rendered, (self.rect.x + style.ui.padding, y))
y += rendered.get_height()
+59
View File
@@ -0,0 +1,59 @@
"""Gemeinsamer Zeichenstil: Farben, Schriften und ein kleiner Text-Cache."""
from __future__ import annotations
import pygame
from sandbox.config import Color, Theme, UiConfig
def _load_font(names: tuple[str, ...], size: int, *, bold: bool = False) -> pygame.font.Font:
"""Lädt die erste verfügbare Systemschrift, sonst die eingebaute pygame-Schrift."""
for name in names:
path = pygame.font.match_font(name)
if path:
font = pygame.font.Font(path, size)
break
else:
font = pygame.font.Font(None, size)
font.bold = bold
return font
class UiStyle:
"""Farben, Maße und Schriften, die alle Widgets und der Renderer gemeinsam nutzen.
Args:
theme: Farbschema.
ui: Schriften und Maße.
"""
def __init__(self, theme: Theme, ui: UiConfig) -> None:
if not pygame.font.get_init():
pygame.font.init()
self.theme = theme
self.ui = ui
self.title_font = _load_font(ui.font_names, ui.title_font_size, bold=True)
self.label_font = _load_font(ui.font_names, ui.label_font_size)
self.small_font = _load_font(ui.font_names, ui.small_font_size)
self._text_cache: dict[tuple[int, str, Color], pygame.Surface] = {}
def render_text(self, text: str, font: pygame.font.Font, color: Color) -> pygame.Surface:
"""Rendert Text (mit Zwischenspeicher, da sich die meisten Texte selten ändern)."""
key = (id(font), text, color)
surface = self._text_cache.get(key)
if surface is None:
if len(self._text_cache) >= self.ui.text_cache_size:
self._text_cache.clear() # einfach und ausreichend: selten, dann komplett
surface = font.render(text, True, color)
self._text_cache[key] = surface
return surface
def shade(color: Color, factor: float) -> Color:
"""Verdunkelt (factor < 1) oder erhellt (factor > 1) eine Farbe."""
return (
max(0, min(255, round(color[0] * factor))),
max(0, min(255, round(color[1] * factor))),
max(0, min(255, round(color[2] * factor))),
)
+500
View File
@@ -0,0 +1,500 @@
"""Selbst gezeichnete Bedienelemente: Beschriftung, Button, Umschalter, Regler, Farbwahl.
Jedes Widget hat ein Rechteck, verarbeitet pygame-Events (:meth:`Widget.handle_event`
liefert True, wenn es das Event verbraucht hat) und zeichnet sich selbst. Zustände wie
"ist die Pause aktiv?" fragen die Widgets über Callbacks ab, statt sie zu kopieren – so
zeigen sie immer den aktuellen Stand, auch wenn er per Tastenkürzel geändert wurde.
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from enum import Enum
from typing import Generic, TypeAlias, TypeVar
import pygame
from sandbox.config import Color
from sandbox.interaction import ShapeKind, ToolSettings
from sandbox.ui.drawing import draw_box, draw_circle
from sandbox.ui.style import UiStyle, shade
T = TypeVar("T")
#: Fester Text oder Funktion, die den aktuellen Text liefert.
TextSource: TypeAlias = str | Callable[[], str]
def _resolve(text: TextSource) -> str:
return text() if callable(text) else text
def _is_left_press(event: pygame.event.Event, rect: pygame.Rect) -> bool:
return (
event.type == pygame.MOUSEBUTTONDOWN
and event.button == pygame.BUTTON_LEFT
and rect.collidepoint(event.pos)
)
def _is_left_release(event: pygame.event.Event) -> bool:
return event.type == pygame.MOUSEBUTTONUP and event.button == pygame.BUTTON_LEFT
class Widget:
"""Basisklasse aller Bedienelemente.
Args:
rect: Position und Größe auf dem Bildschirm.
name: Eindeutiger Name, über den z. B. Tests das Widget finden.
"""
def __init__(self, rect: pygame.Rect, name: str = "") -> None:
self.rect = pygame.Rect(rect)
self.name = name
self.hovered = False
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Verarbeitet ein Event; True, wenn das Widget es verbraucht hat."""
if event.type == pygame.MOUSEMOTION:
self.hovered = self.rect.collidepoint(mouse_pos)
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet das Widget."""
raise NotImplementedError
class LabelKind(Enum):
"""Darstellungsart einer Beschriftung."""
TITLE = "title"
HEADER = "header"
TEXT = "text"
HINT = "hint"
class Label(Widget):
"""Einzeilige Beschriftung (Text fest oder dynamisch über eine Funktion)."""
def __init__(
self, rect: pygame.Rect, text: TextSource, kind: LabelKind = LabelKind.TEXT, name: str = ""
) -> None:
super().__init__(rect, name)
self.text = text
self.kind = kind
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet den Text linksbündig und vertikal zentriert."""
theme = style.theme
font, color = {
LabelKind.TITLE: (style.title_font, theme.text),
LabelKind.HEADER: (style.small_font, theme.text_dim),
LabelKind.TEXT: (style.label_font, theme.text),
LabelKind.HINT: (style.small_font, theme.text_dim),
}[self.kind]
rendered = style.render_text(_resolve(self.text), font, color)
surface.blit(rendered, (self.rect.x, self.rect.centery - rendered.get_height() // 2))
class Button(Widget):
"""Schaltfläche. Löst beim Loslassen über dem Button aus (übliches UI-Verhalten).
Args:
rect: Position und Größe.
text: Beschriftung (fest oder dynamisch).
on_click: Wird beim Klick aufgerufen.
is_active: Optional; hebt den Button hervor, solange die Funktion True liefert.
danger: Rote Darstellung für zerstörerische Aktionen.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
text: TextSource,
on_click: Callable[[], None],
*,
is_active: Callable[[], bool] | None = None,
danger: bool = False,
name: str = "",
) -> None:
super().__init__(rect, name)
self.text = text
self.on_click = on_click
self.is_active = is_active
self.danger = danger
self.pressed = False
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Drücken merkt sich den Button, Loslassen über dem Button löst aus."""
super().handle_event(event, mouse_pos)
if _is_left_press(event, self.rect):
self.pressed = True
return True
if _is_left_release(event) and self.pressed:
self.pressed = False
if self.rect.collidepoint(event.pos):
self.on_click()
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet Hintergrund (je nach Zustand) und zentrierten Text."""
theme = style.theme
active = self.is_active is not None and self.is_active()
if active:
background = theme.panel_active
elif self.hovered:
background = theme.panel_hover
else:
background = theme.panel
radius = style.ui.corner_radius
pygame.draw.rect(surface, background, self.rect, border_radius=radius)
text_color = theme.danger if self.danger else theme.text
if self.danger or active:
border = theme.danger if self.danger else theme.accent
pygame.draw.rect(
surface, border, self.rect, style.ui.border_width, border_radius=radius
)
rendered = style.render_text(_resolve(self.text), style.label_font, text_color)
surface.blit(rendered, rendered.get_rect(center=self.rect.center))
class SegmentedControl(Widget, Generic[T]):
"""Reihe gleich breiter Optionen, von denen genau eine gewählt ist.
Args:
rect: Position und Größe.
options: Paare aus Wert und Beschriftung.
get_value: Liefert den aktuell gewählten Wert.
set_value: Setzt einen neuen Wert.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
options: Sequence[tuple[T, str]],
get_value: Callable[[], T],
set_value: Callable[[T], None],
name: str = "",
) -> None:
super().__init__(rect, name)
self.options = list(options)
self.get_value = get_value
self.set_value = set_value
def segment_rects(self) -> list[pygame.Rect]:
"""Rechtecke der einzelnen Optionen."""
count = len(self.options)
rects = []
for index in range(count):
left = self.rect.x + self.rect.width * index // count
right = self.rect.x + self.rect.width * (index + 1) // count
rects.append(pygame.Rect(left, self.rect.y, right - left, self.rect.height))
return rects
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Ein Klick auf eine Option wählt sie sofort aus."""
super().handle_event(event, mouse_pos)
for (value, _), rect in zip(self.options, self.segment_rects(), strict=True):
if _is_left_press(event, rect):
self.set_value(value)
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet alle Optionen, die gewählte hervorgehoben."""
theme = style.theme
radius = style.ui.corner_radius
pygame.draw.rect(surface, theme.panel, self.rect, border_radius=radius)
current = self.get_value()
for (value, text), rect in zip(self.options, self.segment_rects(), strict=True):
selected = value == current
if selected:
pygame.draw.rect(surface, theme.panel_active, rect, border_radius=radius)
pygame.draw.rect(
surface, theme.accent, rect, style.ui.border_width, border_radius=radius
)
color = theme.text if selected else theme.text_dim
rendered = style.render_text(text, style.label_font, color)
surface.blit(rendered, rendered.get_rect(center=rect.center))
class Slider(Widget):
"""Schieberegler mit Beschriftung und Wertanzeige.
Oben stehen Beschriftung und aktueller Wert, darunter die Schiene mit Knopf. Der Wert
lässt sich klicken, ziehen oder mit dem Mausrad (in ``step``-Schritten) ändern.
Args:
rect: Position und Größe.
label: Beschriftung.
minimum: Kleinster Wert.
maximum: Größter Wert.
step: Rasterung der Werte.
get_value: Liefert den aktuellen Wert.
set_value: Setzt einen neuen Wert.
value_format: Formatiert den Wert für die Anzeige.
knob_radius: Radius des Knopfs in px (bestimmt auch den Rand der Schiene).
is_enabled: Optional; ein deaktivierter Regler ist ausgegraut und reagiert nicht.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
label: str,
*,
minimum: float,
maximum: float,
step: float,
get_value: Callable[[], float],
set_value: Callable[[float], None],
value_format: Callable[[float], str],
knob_radius: int,
is_enabled: Callable[[], bool] | None = None,
name: str = "",
) -> None:
super().__init__(rect, name)
if not (maximum > minimum and step > 0.0):
raise ValueError("Der Regler braucht maximum > minimum und step > 0")
self.label = label
self.minimum = minimum
self.maximum = maximum
self.step = step
self.get_value = get_value
self.set_value = set_value
self.value_format = value_format
self.knob_radius = knob_radius
self.is_enabled = is_enabled
self.dragging = False
@property
def enabled(self) -> bool:
"""Ob der Regler bedienbar ist."""
return self.is_enabled is None or self.is_enabled()
@property
def track_left(self) -> int:
"""x-Koordinate des linken Schienenendes."""
return self.rect.x + self.knob_radius
@property
def track_width(self) -> int:
"""Breite der Schiene in px."""
return self.rect.width - 2 * self.knob_radius
@property
def track_y(self) -> int:
"""y-Koordinate der Schienenmitte."""
return self.rect.bottom - self.knob_radius
def snap(self, value: float) -> float:
"""Rastet einen Wert auf das Raster ein und begrenzt ihn auf [minimum, maximum]."""
steps = round((value - self.minimum) / self.step)
snapped = self.minimum + steps * self.step
return min(max(snapped, self.minimum), self.maximum)
def value_at(self, x: float) -> float:
"""Wert, der zur Bildschirmposition ``x`` auf der Schiene gehört."""
fraction = (x - self.track_left) / self.track_width
fraction = min(max(fraction, 0.0), 1.0)
return self.snap(self.minimum + fraction * (self.maximum - self.minimum))
def x_of(self, value: float) -> float:
"""Bildschirmposition ``x`` eines Werts (Umkehrung von :meth:`value_at`)."""
fraction = (value - self.minimum) / (self.maximum - self.minimum)
return self.track_left + fraction * self.track_width
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Klicken, Ziehen und Mausrad ändern den Wert."""
super().handle_event(event, mouse_pos)
if not self.enabled:
self.dragging = False
return _is_left_press(event, self.rect) # Klick verschlucken, nichts tun
if _is_left_press(event, self.rect):
self.dragging = True
self.set_value(self.value_at(event.pos[0]))
return True
if event.type == pygame.MOUSEMOTION and self.dragging:
self.set_value(self.value_at(mouse_pos[0]))
return True
if _is_left_release(event) and self.dragging:
self.dragging = False
return True
if event.type == pygame.MOUSEWHEEL and self.rect.collidepoint(mouse_pos):
self.set_value(self.snap(self.get_value() + event.y * self.step))
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet Beschriftung, Wert, Schiene und Knopf."""
theme = style.theme
enabled = self.enabled
text_color = theme.text if enabled else theme.text_dim
label = style.render_text(self.label, style.label_font, text_color)
surface.blit(label, (self.rect.x, self.rect.y))
value = style.render_text(
self.value_format(self.get_value()), style.small_font, theme.text_dim
)
# Unterkanten bündig: Der kleinere Wert steht so auf derselben Grundlinie wie der Text.
label_bottom = self.rect.y + label.get_height()
surface.blit(value, value.get_rect(bottomright=(self.rect.right, label_bottom)))
track = pygame.Rect(0, 0, self.track_width, style.ui.slider_track_height)
track.midleft = (self.track_left, self.track_y)
pygame.draw.rect(surface, theme.panel_hover, track, border_radius=track.height)
knob_x = round(self.x_of(self.get_value()))
filled = track.copy()
filled.width = max(0, knob_x - track.x)
fill_color = theme.accent if enabled else theme.text_dim
pygame.draw.rect(surface, fill_color, filled, border_radius=track.height)
knob_color = theme.text if (self.hovered or self.dragging) and enabled else fill_color
pygame.draw.aacircle(surface, knob_color, (knob_x, self.track_y), self.knob_radius)
class ColorPicker(Widget):
"""Farbfelder zur Auswahl; das erste Feld steht für "Zufallsfarbe".
Args:
rect: Position und Größe.
colors: Farbpalette.
get_index: Liefert den gewählten Palettenindex (``None`` = Zufall).
set_index: Setzt den Palettenindex (``None`` = Zufall).
swatch_size: Kantenlänge eines Farbfelds in px.
swatch_gap: Abstand zwischen den Feldern in px.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
colors: Sequence[Color],
get_index: Callable[[], int | None],
set_index: Callable[[int | None], None],
*,
swatch_size: int,
swatch_gap: int,
name: str = "",
) -> None:
super().__init__(rect, name)
self.colors = list(colors)
self.get_index = get_index
self.set_index = set_index
self.swatch_size = swatch_size
self.swatch_gap = swatch_gap
def swatches(self) -> list[tuple[int | None, pygame.Rect]]:
"""Alle Farbfelder mit ihrem Index (zuerst das Zufallsfeld)."""
indices: list[int | None] = [None, *range(len(self.colors))]
pitch = self.swatch_size + self.swatch_gap
return [
(
index,
pygame.Rect(
self.rect.x + i * pitch, self.rect.y, self.swatch_size, self.swatch_size
),
)
for i, index in enumerate(indices)
]
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Ein Klick auf ein Farbfeld wählt es aus."""
super().handle_event(event, mouse_pos)
for index, rect in self.swatches():
if _is_left_press(event, rect):
self.set_index(index)
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet die Felder; das gewählte bekommt einen hellen Ring."""
theme = style.theme
selected = self.get_index()
for index, rect in self.swatches():
if index is None:
# Zufallsfeld: vier Palettenfarben als Viertel.
half = rect.width // 2
quarters = self.colors[:: max(1, len(self.colors) // 4)][:4]
for k, color in enumerate(quarters):
quarter = pygame.Rect(
rect.x + (k % 2) * half, rect.y + (k // 2) * half, half, half
)
pygame.draw.rect(surface, color, quarter)
else:
pygame.draw.rect(surface, self.colors[index], rect)
if index == selected:
gap = style.ui.selection_ring_gap
ring = rect.inflate(2 * gap, 2 * gap)
pygame.draw.rect(surface, theme.highlight, ring, style.ui.selection_ring_width)
class SpawnTile(Widget):
"""Vorschau-Kachel des nächsten Objekts; aus ihr zieht man Objekte in die Welt.
Args:
rect: Position und Größe.
get_settings: Liefert die aktuellen Werkzeug-Einstellungen.
get_color: Liefert die Farbe des nächsten Objekts.
on_drag_start: Wird beim Drücken auf die Kachel aufgerufen.
hint: Hinweistext unter der Vorschau.
name: Name des Widgets.
"""
def __init__(
self,
rect: pygame.Rect,
get_settings: Callable[[], ToolSettings],
get_color: Callable[[], Color],
on_drag_start: Callable[[], None],
*,
hint: str,
name: str = "",
) -> None:
super().__init__(rect, name)
self.get_settings = get_settings
self.get_color = get_color
self.on_drag_start = on_drag_start
self.hint = hint
def handle_event(self, event: pygame.event.Event, mouse_pos: tuple[int, int]) -> bool:
"""Drücken auf die Kachel beginnt das Herausziehen eines neuen Objekts."""
super().handle_event(event, mouse_pos)
if _is_left_press(event, self.rect):
self.on_drag_start()
return True
return False
def draw(self, surface: pygame.Surface, style: UiStyle) -> None:
"""Zeichnet Kachel, maßstäblich verkleinerte Vorschau und Hinweis."""
theme = style.theme
ui = style.ui
background = theme.panel_hover if self.hovered else theme.panel
pygame.draw.rect(surface, background, self.rect, border_radius=ui.corner_radius)
hint = style.render_text(self.hint, style.small_font, theme.text_dim)
hint_rect = hint.get_rect(midbottom=(self.rect.centerx, self.rect.bottom - ui.row_gap))
surface.blit(hint, hint_rect)
settings = self.get_settings()
half_width, half_height = settings.half_extents()
area_height = hint_rect.top - self.rect.top
available = min(self.rect.width, area_height) * ui.preview_scale_limit / 2
scale = min(1.0, available / max(half_width, half_height))
center = (self.rect.centerx, self.rect.top + area_height / 2)
color = self.get_color()
outline = shade(color, theme.outline_shade)
if settings.shape is ShapeKind.CIRCLE:
draw_circle(
surface, color, outline, center, half_width * scale, outline_width=ui.outline_width
)
else:
draw_box(
surface,
color,
outline,
center,
(half_width * scale, half_height * scale),
outline_width=ui.outline_width,
)
+44
View File
@@ -0,0 +1,44 @@
"""Abbildung zwischen Bildschirm- und Weltkoordinaten.
Die Welt liegt rechts neben der Sidebar. Weil beide Koordinatensysteme in Pixeln rechnen
und die y-Achse gleich ausrichten, besteht die Abbildung nur aus einer Verschiebung.
"""
from __future__ import annotations
from sandbox.physics import Vector2
class Viewport:
"""Rechteckiger Bildschirmbereich, in dem die Welt gezeichnet wird.
Args:
left: Linker Rand auf dem Bildschirm in px.
top: Oberer Rand auf dem Bildschirm in px.
width: Breite in px (= Breite der Welt).
height: Höhe in px (= Höhe der Welt).
"""
def __init__(self, left: int, top: int, width: int, height: int) -> None:
self.left = left
self.top = top
self.width = width
self.height = height
@property
def rect(self) -> tuple[int, int, int, int]:
"""Bereich als ``(left, top, width, height)``."""
return (self.left, self.top, self.width, self.height)
def contains(self, screen_pos: tuple[float, float]) -> bool:
"""True, wenn ein Bildschirmpunkt in der Welt liegt."""
x, y = screen_pos
return self.left <= x < self.left + self.width and self.top <= y < self.top + self.height
def to_world(self, screen_pos: tuple[float, float]) -> Vector2:
"""Bildschirmpunkt → Weltpunkt."""
return Vector2(screen_pos[0] - self.left, screen_pos[1] - self.top)
def to_screen(self, point: Vector2) -> tuple[float, float]:
"""Weltpunkt → Bildschirmpunkt."""
return (point.x + self.left, point.y + self.top)
+1
View File
@@ -0,0 +1 @@
"""Testsuite der Physik-Sandbox."""
+1
View File
@@ -0,0 +1 @@
"""Headless-Tests für Oberfläche, Eingaben und Hauptschleife (SDL-Dummy-Treiber)."""
+18
View File
@@ -0,0 +1,18 @@
"""Fixture für die headless bediente Anwendung."""
import random
from collections.abc import Iterator
import pygame
import pytest
from sandbox.main import App
@pytest.fixture
def app() -> Iterator[App]:
"""Frische Anwendung mit reproduzierbaren Zufallsfarben."""
application = App(rng=random.Random(0))
pygame.event.clear()
yield application
application.close()
+68
View File
@@ -0,0 +1,68 @@
"""Hilfsfunktionen, um die Anwendung headless per Event zu bedienen.
Die Tests posten echte pygame-Events in die Event-Queue und rufen dann
:meth:`sandbox.main.App.frame` auf – genau wie die Hauptschleife. So wird die komplette
Kette Event → Sidebar/InputHandler → Werkzeuge → Physik → Zeichnen durchlaufen.
"""
import pygame
from sandbox.main import App
from sandbox.physics import Vector2
#: Dauer eines Test-Frames (60 Bilder/s → je zwei Physikschritte).
FRAME = 1 / 60
def screen_pos(app: App, x: float, y: float) -> tuple[int, int]:
"""Bildschirmposition eines Weltpunkts (auf ganze Pixel gerundet)."""
sx, sy = app.viewport.to_screen(Vector2(x, y))
return (round(sx), round(sy))
def frames(app: App, count: int = 1) -> None:
"""Lässt die Anwendung einige Frames laufen."""
for _ in range(count):
app.frame(FRAME)
def post(event_type: int, **attributes: object) -> None:
"""Legt ein Event in die pygame-Queue."""
pygame.event.post(pygame.event.Event(event_type, **attributes))
def press(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None:
"""Maustaste an ``pos`` drücken (ein Frame)."""
post(pygame.MOUSEBUTTONDOWN, pos=pos, button=button)
frames(app)
def move(app: App, pos: tuple[int, int]) -> None:
"""Maus nach ``pos`` bewegen (ein Frame)."""
post(pygame.MOUSEMOTION, pos=pos, rel=(0, 0), buttons=(1, 0, 0))
frames(app)
def release(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None:
"""Maustaste an ``pos`` loslassen (ein Frame)."""
post(pygame.MOUSEBUTTONUP, pos=pos, button=button)
frames(app)
def click(app: App, pos: tuple[int, int], button: int = pygame.BUTTON_LEFT) -> None:
"""Klick (Drücken und Loslassen) an ``pos``."""
press(app, pos, button)
release(app, pos, button)
def key(app: App, name: str) -> None:
"""Taste drücken (Name wie in ``pygame.key.key_code``)."""
post(pygame.KEYDOWN, key=pygame.key.key_code(name), mod=0, unicode="", scancode=0)
frames(app)
def wheel(app: App, pos: tuple[int, int], steps: int) -> None:
"""Mausrad über ``pos`` drehen."""
move(app, pos)
post(pygame.MOUSEWHEEL, x=0, y=steps, flipped=False)
frames(app)
+332
View File
@@ -0,0 +1,332 @@
"""Integrationstests: Die Anwendung wird headless über echte pygame-Events bedient."""
from dataclasses import replace
from pathlib import Path
import pygame
import pytest
from sandbox.config import APP, INTERACTION, PHYSICS
from sandbox.interaction import ShapeKind
from sandbox.main import App, main
from sandbox.physics import Vector2
from sandbox.ui.widgets import ColorPicker, SegmentedControl, Slider
from tests.app.driver import click, frames, key, move, post, press, release, screen_pos, wheel
# --------------------------------------------------------------------- Erstellen
def test_click_into_the_world_creates_a_body(app: App) -> None:
click(app, screen_pos(app, 400, 300))
(body,) = app.world.bodies
assert body.position.is_close(Vector2(400, 300), tolerance=1.0)
assert body.shape.radius == app.tools.size
assert not body.held
def test_created_body_falls_and_lands_on_the_floor(app: App) -> None:
click(app, screen_pos(app, 400, 300))
frames(app, 240) # Elastizität 0,5: nach ≈ 2,6 s sind alle Rücksprünge abgeklungen
(body,) = app.world.bodies
floor = app.world.height - app.tools.size
assert body.position.y == pytest.approx(floor, abs=PHYSICS.penetration_slop + 1e-3)
def test_sidebar_settings_apply_to_new_bodies(app: App) -> None:
shape = app.sidebar.widget("shape")
colors = app.sidebar.widget("color")
assert isinstance(shape, SegmentedControl)
assert isinstance(colors, ColorPicker)
click(app, shape.segment_rects()[1].center) # "Rechteck"
swatch_index, swatch = colors.swatches()[3]
click(app, swatch.center)
click(app, screen_pos(app, 400, 300))
(body,) = app.world.bodies
assert app.tools.shape is ShapeKind.BOX
assert body.shape.half_extents() == pytest.approx(app.tools.half_extents())
assert body.user_data.color == app.config.theme.body_palette[swatch_index]
def test_drag_from_the_sidebar_creates_a_body_when_entering_the_world(app: App) -> None:
tile = app.sidebar.widget("spawn_tile").rect
press(app, tile.center)
move(app, (tile.right - 5, tile.centery)) # noch über der Sidebar
assert app.world.bodies == []
target = screen_pos(app, 200, 250)
move(app, target)
(body,) = app.world.bodies
assert body.held
release(app, target)
assert not body.held
def test_drag_from_the_sidebar_is_cancelled_when_released_over_the_sidebar(app: App) -> None:
tile = app.sidebar.widget("spawn_tile").rect
press(app, tile.center)
move(app, (tile.centerx, tile.top - 20))
release(app, (tile.centerx, tile.top - 20))
frames(app, 3)
assert app.world.bodies == []
assert not app.input.spawn_drag_active
def test_body_limit_is_respected(app: App) -> None:
app.close()
limited = App(replace(APP, interaction=replace(INTERACTION, max_bodies=2)))
try:
for x in (200, 400, 600):
click(limited, screen_pos(limited, x, 300))
assert len(limited.world.bodies) == 2
assert "(voll)" in limited.status_lines()[0]
finally:
limited.close()
# --------------------------------------------------------------------- Greifen, Ziehen, Werfen
def _resting_body(app: App, x: float) -> tuple[int, int]:
"""Erzeugt einen Körper, lässt ihn landen und liefert seine Bildschirmposition."""
click(app, screen_pos(app, x, 600))
frames(app, 90)
body = app.world.bodies[-1]
return screen_pos(app, body.position.x, body.position.y)
def test_grabbed_body_follows_the_mouse(app: App) -> None:
start = _resting_body(app, 300)
press(app, start)
body = app.drag.body
assert body is not None
for step in range(1, 21):
move(app, (start[0] + 5 * step, start[1] - 10 * step))
frames(app, 10)
expected = app.viewport.to_world((start[0] + 100, start[1] - 200))
assert body.position.is_close(expected, tolerance=1.0)
release(app, (start[0] + 100, start[1] - 200))
def test_release_throws_the_body_with_the_mouse_velocity(app: App) -> None:
start = _resting_body(app, 300)
press(app, start)
body = app.drag.body
assert body is not None
x, y = start
for _ in range(20): # 10 px pro Frame bei 60 Bildern/s = 600 px/s nach rechts
x += 10
move(app, (x, y - 150))
release(app, (x, y - 150))
assert body.velocity.x == pytest.approx(600, rel=0.05)
frames(app, 10)
assert body.position.x > app.viewport.to_world((x, y)).x # fliegt weiter
def test_holding_still_before_release_does_not_throw(app: App) -> None:
start = _resting_body(app, 300)
press(app, start)
body = app.drag.body
assert body is not None
target = (start[0] + 200, start[1] - 150)
for step in range(1, 11):
move(app, (start[0] + 20 * step, start[1] - 15 * step))
frames(app, 30) # eine halbe Sekunde stillhalten
release(app, target)
assert abs(body.velocity.x) < 1
def test_dragged_body_pushes_other_bodies(app: App) -> None:
obstacle_pos = _resting_body(app, 300)
obstacle = app.world.bodies[-1]
start = _resting_body(app, 600)
press(app, start)
held = app.drag.body
assert held is not None and held is not obstacle
for step in range(1, 31): # nach links durch das Hindernis hindurch ziehen
move(app, (start[0] - 15 * step, start[1]))
assert obstacle.position.x < app.viewport.to_world(obstacle_pos).x - 50
overlap = (
held.shape.radius + obstacle.shape.radius - held.position.distance_to(obstacle.position)
)
assert overlap < PHYSICS.max_position_correction
release(app, (start[0] - 450, start[1]))
def test_dragged_body_cannot_leave_the_world(app: App) -> None:
start = _resting_body(app, 300)
press(app, start)
body = app.drag.body
assert body is not None
move(app, (5, 5)) # über die Sidebar hinaus
frames(app, 30)
radius = body.shape.radius
assert body.position.x >= radius - PHYSICS.max_wall_penetration
assert body.position.y >= radius - PHYSICS.max_wall_penetration
release(app, (5, 5))
def test_paused_drag_places_the_body_without_throwing(app: App) -> None:
start = _resting_body(app, 300)
key(app, "space")
assert app.paused
# Versatz zwischen Griffpunkt (ganze Pixel) und Schwerpunkt, gemessen vor dem Greifen.
grip_offset = app.world.bodies[-1].position - app.viewport.to_world(start)
press(app, start)
body = app.drag.body
assert body is app.world.bodies[-1]
target = (start[0] + 120, start[1] - 200)
move(app, target)
expected = app.viewport.to_world(target) + grip_offset
assert body.position.is_close(expected, tolerance=1e-6)
release(app, target)
assert body.velocity == Vector2(0, 0)
frames(app, 10)
assert body.position.is_close(expected, tolerance=1e-6) # bleibt: Simulation pausiert
# --------------------------------------------------------------------- Löschen
def test_right_click_deletes_a_body(app: App) -> None:
pos = _resting_body(app, 300)
click(app, pos, pygame.BUTTON_RIGHT)
assert app.world.bodies == []
def test_delete_key_removes_the_body_under_the_mouse(app: App) -> None:
pos = _resting_body(app, 300)
move(app, pos)
assert app.input.hovered_body is app.world.bodies[0]
key(app, "delete")
assert app.world.bodies == []
def test_right_click_on_the_held_body_releases_and_deletes_it(app: App) -> None:
pos = _resting_body(app, 300)
press(app, pos)
press(app, pos, pygame.BUTTON_RIGHT)
assert app.world.bodies == []
assert app.drag.body is None
release(app, pos)
def test_clear_button_removes_everything(app: App) -> None:
for x in (200, 400, 600):
click(app, screen_pos(app, x, 300))
click(app, app.sidebar.widget("clear").rect.center)
assert app.world.bodies == []
# --------------------------------------------------------------------- Sidebar und Tastatur
def test_pause_button_freezes_and_resumes_the_simulation(app: App) -> None:
click(app, screen_pos(app, 400, 100))
body = app.world.bodies[0]
pause = app.sidebar.widget("pause").rect.center
click(app, pause)
assert app.paused
frozen = body.position
frames(app, 30)
assert body.position == frozen
click(app, pause)
frames(app, 10)
assert body.position.y > frozen.y
def test_gravity_button_toggles_gravity(app: App) -> None:
click(app, app.sidebar.widget("gravity").rect.center)
assert not app.world.gravity_enabled
click(app, screen_pos(app, 400, 100))
frames(app, 30)
assert app.world.bodies[0].position.is_close(Vector2(400, 100), tolerance=1e-6)
def test_button_only_triggers_when_released_over_it(app: App) -> None:
button = app.sidebar.widget("pause").rect
press(app, button.center)
release(app, (button.centerx, button.bottom + 40))
assert not app.paused
def test_size_slider_can_be_clicked_dragged_and_scrolled(app: App) -> None:
slider = app.sidebar.widget("size")
assert isinstance(slider, Slider)
press(app, (slider.rect.right - 1, slider.track_y))
assert app.tools.size == INTERACTION.max_size
move(app, (slider.rect.left - 50, slider.track_y)) # Ziehen darf die Sidebar verlassen
assert app.tools.size == INTERACTION.min_size
release(app, (slider.rect.left - 50, slider.track_y))
wheel(app, slider.rect.center, 3)
assert app.tools.size == INTERACTION.min_size + 3 * INTERACTION.size_step
def test_aspect_slider_is_disabled_for_circles(app: App) -> None:
slider = app.sidebar.widget("aspect_ratio")
assert isinstance(slider, Slider)
before = app.tools.aspect_ratio
click(app, (slider.rect.right - 1, slider.track_y))
assert app.tools.aspect_ratio == before
key(app, "2")
click(app, (slider.rect.right - 1, slider.track_y))
assert app.tools.aspect_ratio == INTERACTION.max_aspect_ratio
def test_mouse_wheel_over_the_world_changes_the_size(app: App) -> None:
before = app.tools.size
wheel(app, screen_pos(app, 400, 300), 2)
assert app.tools.size == before + 2 * INTERACTION.size_wheel_step
wheel(app, screen_pos(app, 400, 300), -1000)
assert app.tools.size == INTERACTION.min_size
def test_keyboard_shortcuts(app: App) -> None:
key(app, "space")
assert app.paused
key(app, "space")
assert not app.paused
key(app, "g")
assert not app.world.gravity_enabled
key(app, "2")
assert app.tools.shape is ShapeKind.BOX
key(app, "1")
assert app.tools.shape is ShapeKind.CIRCLE
key(app, "f1")
assert app.debug
click(app, screen_pos(app, 400, 300))
key(app, "c")
assert app.world.bodies == []
key(app, "x") # nicht belegt: keine Wirkung
key(app, "escape")
assert not app.running
def test_closing_the_window_stops_the_main_loop(app: App) -> None:
post(pygame.QUIT)
frames(app)
assert not app.running
# --------------------------------------------------------------------- Darstellung und Start
def test_debug_view_and_demo_scene_render_without_errors(app: App) -> None:
app.load_demo_scene()
key(app, "f1")
frames(app, 30)
lines = app._debug_lines()
assert lines[0].startswith("Körper: 26")
move(app, screen_pos(app, 100, 100)) # Vorschau über leerer Welt
assert app.input.spawn_preview() is not None
def test_screenshot_is_written(app: App, tmp_path: Path) -> None:
path = tmp_path / "shot.png"
frames(app)
app.save_screenshot(path)
assert path.stat().st_size > 0
def test_command_line_screenshot_mode(tmp_path: Path) -> None:
path = tmp_path / "cli.png"
assert main(["--demo", "--screenshot", str(path), "--seconds", "0.2"]) == 0
assert path.exists()
@@ -0,0 +1,111 @@
"""Einstiegspunkte (``python -m sandbox``, CLI) und Randfälle der Eingabeverarbeitung."""
import runpy
import sys
from dataclasses import replace
from pathlib import Path
import pygame
import pytest
from sandbox import main as main_module
from sandbox.config import APP, INTERACTION, Theme, UiConfig
from sandbox.main import App, FrameStats, main
from sandbox.ui.style import UiStyle, _load_font
from tests.app.driver import click, frames, key, move, post, press, release, screen_pos
def test_python_dash_m_starts_the_app(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
screenshot = tmp_path / "module.png"
monkeypatch.setattr(
sys, "argv", ["sandbox", "--screenshot", str(screenshot), "--seconds", "0.1"]
)
with pytest.raises(SystemExit) as exit_info:
runpy.run_module("sandbox", run_name="__main__")
assert exit_info.value.code == 0
assert screenshot.exists()
def test_main_without_options_runs_the_interactive_loop(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[float | None] = []
monkeypatch.setattr(
main_module.App, "run", lambda self, max_seconds=None: calls.append(max_seconds)
)
assert main([]) == 0
assert calls == [None]
def test_main_loop_ends_when_the_window_is_closed(app: App) -> None:
post(pygame.QUIT)
app.run() # kehrt nach dem ersten Frame zurück
assert not app.running
def test_frame_stats_are_smoothed_and_ignore_empty_frames() -> None:
stats = FrameStats(smoothing=0.5)
stats.update(1 / 50, 0.002)
assert stats.fps == pytest.approx(50)
stats.update(1 / 100, 0.004)
assert stats.fps == pytest.approx(75)
stats.update(0.0, 0.0) # z. B. allererster Frame: keine Division durch 0
assert stats.fps == pytest.approx(75)
assert stats.physics_ms > 0
def test_clicks_outside_the_world_without_sidebar_are_ignored(app: App) -> None:
app.input.sidebar = None # InputHandler auch ohne Sidebar nutzbar
click(app, (10, 10)) # links neben der Welt
assert app.world.bodies == []
click(app, screen_pos(app, 400, 300))
assert len(app.world.bodies) == 1
def test_middle_click_and_delete_without_target_do_nothing(app: App) -> None:
click(app, screen_pos(app, 400, 300), pygame.BUTTON_MIDDLE)
assert app.world.bodies == []
move(app, screen_pos(app, 600, 300))
key(app, "delete") # nichts unter der Maus
click(app, screen_pos(app, 600, 300), pygame.BUTTON_RIGHT)
assert app.world.bodies == []
def test_spawn_drag_is_ignored_when_the_world_is_full(app: App) -> None:
app.close()
full = App(replace(APP, interaction=replace(INTERACTION, max_bodies=1)))
try:
click(full, screen_pos(full, 400, 300))
tile = full.sidebar.widget("spawn_tile").rect
press(full, tile.center)
move(full, screen_pos(full, 200, 200))
assert len(full.world.bodies) == 1
assert full.drag.body is None
assert full.input.spawn_preview() is None # voll: keine Vorschau mehr
release(full, screen_pos(full, 200, 200))
frames(full)
finally:
full.close()
def test_unknown_widget_names_raise(app: App) -> None:
with pytest.raises(KeyError):
app.sidebar.widget("gibt-es-nicht")
def test_missing_system_fonts_fall_back_to_the_builtin_font() -> None:
pygame.font.init()
try:
font = _load_font(("keine-solche-schrift-xyz",), 14, bold=True)
assert font.bold
assert font.size("Test")[0] > 0
finally:
pygame.font.quit()
def test_style_initializes_the_font_module_itself() -> None:
pygame.font.quit()
style = UiStyle(Theme(), UiConfig())
try:
assert pygame.font.get_init()
assert style.render_text("x", style.label_font, (0, 0, 0)).get_width() > 0
finally:
pygame.font.quit()
+77
View File
@@ -0,0 +1,77 @@
"""Smoke-Test: Die echte Hauptschleife läuft einige Sekunden headless mit Zufallseingaben.
Ein simulierter, zufällig klickender "Affe" bedient dabei alles, was ein Mensch auch
bedienen könnte: Klicks in Welt und Sidebar, Ziehen und Werfen, Rechtsklicks, Mausrad
und Tastenkürzel. Der Test gilt als bestanden, wenn keine Exception auftritt und die
Simulation danach in einem gültigen Zustand ist.
"""
import random
import pygame
import pytest
from sandbox.config import PHYSICS
from sandbox.main import App
SMOKE_SECONDS = 3.0
SAFE_KEYS = ("space", "g", "c", "delete", "1", "2", "f1") # alles außer "Beenden"
class MonkeyApp(App):
"""Die echte Anwendung, die sich vor jedem Frame selbst zufällige Events schickt."""
def __init__(self, seed: int) -> None:
super().__init__(rng=random.Random(seed))
self.monkey = random.Random(seed)
self.frame_count = 0
def frame(self, frame_time: float) -> None:
self._post_random_events()
super().frame(frame_time)
self.frame_count += 1
def _random_position(self) -> tuple[int, int]:
return (
self.monkey.randrange(self.config.window.width),
self.monkey.randrange(self.config.window.height),
)
def _post_random_events(self) -> None:
roll = self.monkey.random()
position = self._random_position()
if roll < 0.25:
button = self.monkey.choice([pygame.BUTTON_LEFT, pygame.BUTTON_RIGHT])
pygame.event.post(
pygame.event.Event(pygame.MOUSEBUTTONDOWN, pos=position, button=button)
)
elif roll < 0.45:
pygame.event.post(pygame.event.Event(pygame.MOUSEBUTTONUP, pos=position, button=1))
elif roll < 0.85:
pygame.event.post(
pygame.event.Event(pygame.MOUSEMOTION, pos=position, rel=(0, 0), buttons=(1, 0, 0))
)
elif roll < 0.93:
pygame.event.post(
pygame.event.Event(pygame.MOUSEWHEEL, x=0, y=self.monkey.choice([-1, 1]))
)
else:
key = pygame.key.key_code(self.monkey.choice(SAFE_KEYS))
pygame.event.post(
pygame.event.Event(pygame.KEYDOWN, key=key, mod=0, unicode="", scancode=0)
)
@pytest.mark.slow
def test_main_loop_survives_seconds_of_random_input() -> None:
app = MonkeyApp(seed=7)
try:
app.load_demo_scene()
app.run(max_seconds=SMOKE_SECONDS)
assert app.running
assert app.frame_count > 0
for body in app.world.bodies:
assert body.position.is_finite()
assert body.velocity.length() <= PHYSICS.max_speed + 1e-6
finally:
app.close()
+193
View File
@@ -0,0 +1,193 @@
"""Tests einzelner Widgets (headless) sowie kleiner Hilfsbausteine der Oberfläche."""
import math
from collections.abc import Iterator
import pygame
import pytest
from sandbox.config import INTERACTION, Theme, UiConfig
from sandbox.interaction import ShapeKind, ToolSettings
from sandbox.renderer import interpolate_angle
from sandbox.ui.style import UiStyle, shade
from sandbox.ui.widgets import (
Button,
ColorPicker,
Label,
LabelKind,
SegmentedControl,
Slider,
SpawnTile,
Widget,
)
from sandbox.viewport import Viewport
@pytest.fixture
def style() -> Iterator[UiStyle]:
pygame.init()
yield UiStyle(Theme(), UiConfig())
pygame.quit()
def _event(event_type: int, **attributes: object) -> pygame.event.Event:
return pygame.event.Event(event_type, **attributes)
def _press(pos: tuple[int, int]) -> pygame.event.Event:
return _event(pygame.MOUSEBUTTONDOWN, pos=pos, button=pygame.BUTTON_LEFT)
def _release(pos: tuple[int, int]) -> pygame.event.Event:
return _event(pygame.MOUSEBUTTONUP, pos=pos, button=pygame.BUTTON_LEFT)
def _slider(value: list[float], enabled: bool = True) -> Slider:
return Slider(
pygame.Rect(0, 0, 214, 38),
"Test",
minimum=10,
maximum=110,
step=5,
get_value=lambda: value[0],
set_value=lambda new: value.__setitem__(0, new),
value_format=str,
knob_radius=7,
is_enabled=lambda: enabled,
)
def test_slider_maps_positions_to_snapped_values() -> None:
slider = _slider([10.0])
assert slider.value_at(slider.track_left) == 10
assert slider.value_at(slider.track_left + slider.track_width) == 110
assert slider.value_at(slider.track_left + slider.track_width / 2) == 60
assert slider.value_at(-1000) == 10 # außerhalb wird begrenzt
assert slider.snap(23) == 25
assert slider.x_of(60) == pytest.approx(slider.track_left + slider.track_width / 2)
def test_slider_drag_changes_the_value_until_released() -> None:
value = [10.0]
slider = _slider(value)
assert slider.handle_event(_press((slider.track_left, slider.track_y)), (0, 0))
motion = _event(pygame.MOUSEMOTION, pos=(500, 20))
assert slider.handle_event(motion, (500, 20))
assert value[0] == 110
assert slider.handle_event(_release((500, 20)), (500, 20))
assert not slider.dragging
assert not slider.handle_event(motion, (500, 20)) # nach dem Loslassen: kein Ziehen mehr
def test_disabled_slider_swallows_clicks_but_does_nothing() -> None:
value = [10.0]
slider = _slider(value, enabled=False)
assert slider.handle_event(_press((100, 30)), (100, 30))
assert value[0] == 10
assert not slider.handle_event(_event(pygame.MOUSEWHEEL, x=0, y=1), (100, 30))
def test_slider_rejects_an_empty_range() -> None:
with pytest.raises(ValueError, match="maximum > minimum"):
Slider(
pygame.Rect(0, 0, 100, 38),
"x",
minimum=1,
maximum=1,
step=1,
get_value=lambda: 1.0,
set_value=lambda _: None,
value_format=str,
knob_radius=7,
)
def test_segmented_control_and_color_picker_select_on_click() -> None:
selected: list[object] = [ShapeKind.CIRCLE, None]
control = SegmentedControl(
pygame.Rect(0, 0, 200, 30),
[(ShapeKind.CIRCLE, "Kreis"), (ShapeKind.BOX, "Rechteck")],
lambda: selected[0],
lambda value: selected.__setitem__(0, value),
)
assert control.handle_event(_press(control.segment_rects()[1].center), (0, 0))
assert selected[0] is ShapeKind.BOX
assert not control.handle_event(_press((500, 500)), (500, 500))
picker = ColorPicker(
pygame.Rect(0, 40, 250, 22),
Theme().body_palette,
lambda: selected[1], # type: ignore[return-value]
lambda index: selected.__setitem__(1, index),
swatch_size=22,
swatch_gap=3,
)
swatches = picker.swatches()
assert swatches[0][0] is None # erstes Feld = Zufallsfarbe
assert picker.handle_event(_press(swatches[2][1].center), (0, 0))
assert selected[1] == 1
assert picker.handle_event(_press(swatches[0][1].center), (0, 0))
assert selected[1] is None
def test_all_widgets_draw_in_every_state(style: UiStyle) -> None:
surface = pygame.Surface((300, 600))
clicks: list[str] = []
tools = ToolSettings.from_config(INTERACTION)
widgets: list[Widget] = [
Label(pygame.Rect(0, 0, 200, 20), "Titel", LabelKind.TITLE),
Label(pygame.Rect(0, 20, 200, 20), lambda: "dynamisch", LabelKind.HINT),
Button(
pygame.Rect(0, 40, 100, 30), "Aktiv", lambda: clicks.append("a"), is_active=lambda: True
),
Button(pygame.Rect(0, 80, 100, 30), "Gefahr", lambda: None, danger=True),
_slider([60.0]),
_slider([60.0], enabled=False),
SpawnTile(
pygame.Rect(0, 300, 228, 92),
lambda: tools,
lambda: (200, 100, 50),
lambda: clicks.append("spawn"),
hint="Hinweis",
),
]
for widget in widgets:
widget.handle_event(_event(pygame.MOUSEMOTION, pos=widget.rect.center), widget.rect.center)
widget.draw(surface, style)
tools.shape = ShapeKind.BOX
widgets[-1].draw(surface, style)
assert widgets[-1].handle_event(_press(widgets[-1].rect.center), (0, 0))
assert clicks == ["spawn"]
def test_base_widget_cannot_be_drawn(style: UiStyle) -> None:
with pytest.raises(NotImplementedError):
Widget(pygame.Rect(0, 0, 1, 1)).draw(pygame.Surface((1, 1)), style)
def test_text_cache_is_bounded(style: UiStyle) -> None:
for number in range(style.ui.text_cache_size + 10):
style.render_text(str(number), style.small_font, (255, 255, 255))
assert len(style._text_cache) <= style.ui.text_cache_size
def test_shade_clamps_to_valid_colors() -> None:
assert shade((100, 200, 250), 0.5) == (50, 100, 125)
assert shade((100, 200, 250), 2.0) == (200, 255, 255)
def test_viewport_maps_between_screen_and_world() -> None:
viewport = Viewport(260, 0, 1020, 720)
point = viewport.to_world((300, 50))
assert (point.x, point.y) == (40, 50)
assert viewport.to_screen(point) == (300, 50)
assert viewport.contains((260, 0))
assert not viewport.contains((259, 10))
assert not viewport.contains((1280, 10))
def test_angle_interpolation_takes_the_short_way_around() -> None:
assert interpolate_angle(0.0, 1.0, 0.5) == pytest.approx(0.5)
# Von knapp unter +π nach knapp über −π ist nur ein kleiner Schritt.
halfway = interpolate_angle(math.pi - 0.1, -math.pi + 0.1, 0.5)
assert math.cos(halfway) == pytest.approx(-1.0)
+12
View File
@@ -0,0 +1,12 @@
"""Gemeinsame pytest-Konfiguration.
Die Umgebungsvariablen müssen gesetzt sein, *bevor* irgendein Test pygame importiert:
Mit dem Dummy-Treiber öffnet SDL kein echtes Fenster und braucht keine Soundkarte – so
laufen UI- und Integrationstests auch headless (z. B. in CI).
"""
import os
os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
os.environ.setdefault("PYGAME_HIDE_SUPPORT_PROMPT", "1")
+81
View File
@@ -0,0 +1,81 @@
"""Hilfsfunktionen für die Physik-Tests (Szenen aufbauen, simulieren, Erhaltungsgrößen)."""
from __future__ import annotations
from dataclasses import replace
from sandbox.config import PHYSICS, TIME_STEP
from sandbox.physics import Body, Material, Vector2, World
WORLD_WIDTH = 1000.0
WORLD_HEIGHT = 700.0
def make_world(
width: float = WORLD_WIDTH, height: float = WORLD_HEIGHT, **overrides: object
) -> World:
"""Welt mit Standardkonfiguration; einzelne Parameter lassen sich überschreiben."""
return World(width, height, replace(PHYSICS, **overrides))
def material(density: float = 1.0, restitution: float = 0.5, friction: float = 0.5) -> Material:
"""Material mit gut lesbaren Standardwerten."""
return Material(density=density, restitution=restitution, friction=friction)
def add_circle(
world: World,
x: float,
y: float,
radius: float,
*,
velocity: tuple[float, float] = (0.0, 0.0),
angular_velocity: float = 0.0,
**material_values: float,
) -> Body:
"""Fügt einen Kreis hinzu."""
body = Body.circle(
Vector2(x, y), radius, material(**material_values), velocity=Vector2(*velocity)
)
body.angular_velocity = angular_velocity
return world.add_body(body)
def add_box(
world: World,
x: float,
y: float,
half_width: float,
half_height: float,
*,
velocity: tuple[float, float] = (0.0, 0.0),
**material_values: float,
) -> Body:
"""Fügt ein achsenparalleles Rechteck hinzu."""
body = Body.box(
Vector2(x, y),
half_width,
half_height,
material(**material_values),
velocity=Vector2(*velocity),
)
return world.add_body(body)
def simulate(world: World, seconds: float, dt: float = TIME_STEP) -> None:
"""Führt so viele feste Schritte aus, wie in ``seconds`` passen."""
for _ in range(round(seconds / dt)):
world.step(dt)
def total_momentum(bodies: list[Body]) -> Vector2:
"""Summe der Impulse m·v."""
total = Vector2()
for body in bodies:
total = total + body.momentum()
return total
def total_kinetic_energy(bodies: list[Body]) -> float:
"""Summe der Bewegungsenergien (translatorisch + rotatorisch)."""
return sum(body.kinetic_energy() for body in bodies)
+1
View File
@@ -0,0 +1 @@
"""Tests der pygame-freien Werkzeug-Logik."""
+29
View File
@@ -0,0 +1,29 @@
"""Tests der Beispielszene."""
import random
from sandbox.config import INTERACTION, TIME_STEP, DemoSceneConfig, Theme
from sandbox.demo import build_demo_scene
from sandbox.interaction import BodyFactory, ToolSettings
from tests.helpers import make_world
def test_demo_scene_fits_into_the_world_and_settles() -> None:
world = make_world(1020, 720)
factory = BodyFactory(world, INTERACTION, Theme().body_palette, random.Random(0))
scene = DemoSceneConfig()
build_demo_scene(factory, ToolSettings.from_config(INTERACTION), scene)
pyramid = scene.pyramid_rows * (scene.pyramid_rows + 1) // 2
assert len(world.bodies) == scene.tower_levels + pyramid + scene.ball_count
for body in world.bodies:
min_x, min_y, max_x, max_y = body.bounds()
assert min_x >= 0 and max_x <= world.width
assert min_y >= 0 and max_y <= world.height
for _ in range(round(4 / TIME_STEP)):
world.step(TIME_STEP)
tower = world.bodies[: scene.tower_levels]
assert all(body.sleeping for body in tower) # der Turm steht ruhig
tops = [body.position.y for body in tower]
assert tops == sorted(tops, reverse=True) # und ist nicht eingestürzt
+226
View File
@@ -0,0 +1,226 @@
"""Tests für Greifen/Ziehen/Werfen und das Erzeugen von Körpern (ohne pygame)."""
import math
import random
from dataclasses import replace
import pytest
from sandbox.config import INTERACTION, MIN_BODY_SIZE, PHYSICS, TIME_STEP, Theme
from sandbox.interaction import BodyFactory, BodyStyle, DragController, ShapeKind, ToolSettings
from sandbox.physics import Box, Circle, Vector2
from tests.helpers import add_box, add_circle, make_world
PALETTE = Theme().body_palette
def _factory(**overrides: object) -> BodyFactory:
return BodyFactory(make_world(), replace(INTERACTION, **overrides), PALETTE, random.Random(1))
def _settings(**overrides: object) -> ToolSettings:
return replace(ToolSettings.from_config(INTERACTION), **overrides)
# --------------------------------------------------------------------- Werkzeug-Einstellungen
def test_circle_half_extents_are_the_radius() -> None:
assert _settings(shape=ShapeKind.CIRCLE, size=30).half_extents() == (30, 30)
def test_box_aspect_ratio_changes_shape_but_not_area() -> None:
wide = _settings(shape=ShapeKind.BOX, size=30, aspect_ratio=2.0).half_extents()
square = _settings(shape=ShapeKind.BOX, size=30, aspect_ratio=1.0).half_extents()
assert wide[0] > wide[1]
assert square == pytest.approx((30, 30))
assert wide[0] * wide[1] == pytest.approx(square[0] * square[1])
def test_box_sides_never_drop_below_the_minimum_size() -> None:
thin = _settings(shape=ShapeKind.BOX, size=MIN_BODY_SIZE, aspect_ratio=2.0).half_extents()
assert min(thin) == pytest.approx(MIN_BODY_SIZE)
# --------------------------------------------------------------------- Körper erzeugen
def test_factory_creates_circles_and_boxes_with_the_selected_material() -> None:
factory = _factory()
circle = factory.create(_settings(size=20, density=2.0, restitution=0.9), Vector2(300, 300))
assert circle is not None
assert isinstance(circle.shape, Circle)
assert circle.mass == pytest.approx(math.pi * 20**2 * 2.0)
assert circle.material.restitution == 0.9
assert circle.material.friction == INTERACTION.friction
box = factory.create(_settings(shape=ShapeKind.BOX, size=20), Vector2(500, 300))
assert box is not None
assert isinstance(box.shape, Box)
assert factory.world.bodies == [circle, box]
def test_factory_moves_new_bodies_completely_into_the_world() -> None:
factory = _factory()
body = factory.create(_settings(size=30), Vector2(-100, 5000))
assert body is not None
assert body.position == Vector2(30, factory.world.height - 30)
def test_factory_respects_the_body_limit() -> None:
factory = _factory(max_bodies=2)
settings = _settings()
assert factory.create(settings, Vector2(100, 100)) is not None
assert factory.create(settings, Vector2(200, 100)) is not None
assert factory.is_full
assert factory.create(settings, Vector2(300, 100)) is None
assert len(factory.world.bodies) == 2
def test_preview_color_is_the_color_of_the_next_body() -> None:
factory = _factory()
settings = _settings(color_index=None)
for _ in range(10):
announced = factory.color_for(settings)
body = factory.create(settings, Vector2(300, 300))
assert body is not None
assert body.user_data == BodyStyle(announced)
def test_selected_palette_color_is_used() -> None:
factory = _factory()
body = factory.create(_settings(color_index=3), Vector2(300, 300))
assert body is not None
assert body.user_data == BodyStyle(PALETTE[3])
# --------------------------------------------------------------------- Ziehen und Werfen
def test_grab_marks_the_body_as_held_and_keeps_the_grip_offset() -> None:
world = make_world()
body = add_circle(world, 300, 300, 20)
body.put_to_sleep()
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(310, 295), time=0.0)
assert drag.body is body
assert body.held
assert not body.sleeping
drag.move(Vector2(410, 295), time=0.1)
assert drag.target == Vector2(400, 300) # Griffversatz (−10, +5) bleibt erhalten
def test_target_is_clamped_so_the_body_fits_into_the_world() -> None:
world = make_world()
body = add_circle(world, 300, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(300, 300), time=0.0)
drag.move(Vector2(-500, -500), time=0.1)
assert drag.target == Vector2(20, 20)
def test_held_body_follows_the_pointer_through_the_physics() -> None:
world = make_world()
body = add_circle(world, 300, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(300, 300), time=0.0)
drag.move(Vector2(450, 250), time=0.0)
for _ in range(60):
drag.apply(TIME_STEP)
world.step(TIME_STEP)
assert body.position.is_close(Vector2(450, 250), tolerance=0.5)
def test_held_body_speed_is_limited() -> None:
world = make_world()
body = add_circle(world, 30, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(30, 300), time=0.0)
drag.move(Vector2(980, 300), time=0.0)
drag.apply(TIME_STEP)
assert body.velocity.length() == pytest.approx(PHYSICS.max_speed)
def test_held_body_still_collides_and_pushes_other_bodies() -> None:
world = make_world()
obstacle = add_box(world, 500, 680, 20, 20)
held = add_box(world, 300, 680, 20, 20)
drag = DragController(world, INTERACTION)
drag.grab(held, Vector2(300, 680), time=0.0)
drag.move(Vector2(700, 680), time=0.0)
for _ in range(120):
drag.apply(TIME_STEP)
world.step(TIME_STEP)
assert obstacle.position.x > 520 # wurde weggeschoben
assert obstacle.position.x - held.position.x >= 40 - PHYSICS.penetration_slop - 1e-3
def test_held_body_pressed_against_an_obstacle_does_not_jitter() -> None:
"""Ein gegen ein blockiertes Hindernis gedrückter, gehaltener Körper steht ruhig.
Würde er abprallen, zöge ihn die Maus im nächsten Schritt wieder heran – er zitterte.
"""
world = make_world()
add_box(world, 1000 - 20, 680, 20, 20, restitution=1.0) # liegt an der rechten Wand
held = add_box(world, 700, 680, 20, 20, restitution=1.0)
drag = DragController(world, INTERACTION)
drag.grab(held, Vector2(700, 680), time=0.0)
drag.move(Vector2(990, 680), time=0.0) # Ziel liegt hinter dem Hindernis
positions = []
for step in range(180):
drag.apply(TIME_STEP)
world.step(TIME_STEP)
if step >= 120:
positions.append(held.position.x)
assert max(positions) - min(positions) < 0.1
# Wand → Hindernis → gehaltener Körper: Jeder der beiden Kontakte behält höchstens den
# Slop als Resttiefe, der Körper steht also bei x ≤ 1000 − 40 − 20 + 2·Slop.
assert max(positions) <= 1000 - 40 - 20 + 2 * PHYSICS.penetration_slop + 1e-3
def test_release_throws_with_the_smoothed_pointer_velocity() -> None:
world = make_world(gravity=(0.0, 0.0))
body = add_circle(world, 200, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(200, 300), time=0.0)
for frame in range(1, 31):
t = frame / 60
drag.move(Vector2(200 + 600 * t, 300 - 150 * t), time=t)
assert drag.release(paused=False) is body
assert body.velocity.is_close(Vector2(600, -150), tolerance=5.0)
assert not body.held
assert drag.body is None
def test_throw_speed_is_limited() -> None:
world = make_world()
body = add_circle(world, 200, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(200, 300), time=0.0)
for frame in range(1, 11):
drag.move(Vector2(200 + 100 * frame, 300), time=frame / 120) # 12 000 px/s
drag.release(paused=False)
assert body.velocity.length() == pytest.approx(INTERACTION.max_throw_speed)
def test_release_while_paused_does_not_throw() -> None:
world = make_world()
body = add_circle(world, 200, 300, 20)
drag = DragController(world, INTERACTION)
drag.grab(body, Vector2(200, 300), time=0.0)
drag.move(Vector2(260, 300), time=0.05)
drag.place()
assert body.position == Vector2(260, 300)
drag.release(paused=True)
assert body.velocity == Vector2(0, 0)
def test_cancel_and_release_without_body_are_harmless() -> None:
world = make_world()
drag = DragController(world, INTERACTION)
drag.cancel()
drag.apply(TIME_STEP)
drag.place()
drag.move(Vector2(1, 1), time=0.0)
assert drag.release(paused=False) is None
assert drag.throw_velocity == Vector2(0, 0)
@@ -0,0 +1,75 @@
"""Tests der geglätteten Wurfgeschwindigkeit."""
import random
import pytest
from sandbox.interaction import VelocityTracker
from sandbox.physics import Vector2
WINDOW = 0.1
SMOOTHING = 0.05
def _track(velocity: Vector2, fps: float, seconds: float) -> VelocityTracker:
"""Bewegt einen Zeiger mit konstanter Geschwindigkeit und liefert den Tracker."""
tracker = VelocityTracker(WINDOW, SMOOTHING)
tracker.reset(0.0, Vector2(0, 0))
frames = round(seconds * fps)
for frame in range(1, frames + 1):
t = frame / fps
tracker.add_sample(t, velocity * t)
return tracker
def test_constant_motion_is_measured_exactly() -> None:
tracker = _track(Vector2(600, -200), fps=60, seconds=0.5)
assert tracker.velocity.is_close(Vector2(600, -200), tolerance=1.0)
def test_result_does_not_depend_on_the_frame_rate() -> None:
slow = _track(Vector2(500, 0), fps=30, seconds=0.5)
fast = _track(Vector2(500, 0), fps=144, seconds=0.5)
assert slow.velocity.x == pytest.approx(fast.velocity.x, rel=0.01)
def test_velocity_decays_when_the_pointer_stops() -> None:
tracker = _track(Vector2(800, 0), fps=60, seconds=0.3)
resting = Vector2(800 * 0.3, 0)
for frame in range(1, 31): # eine halbe Sekunde stillhalten
tracker.add_sample(0.3 + frame / 60, resting)
if frame == 12: # nach 0,2 s ist der Schwung auf wenige Prozent abgeklungen
assert tracker.velocity.length() < 0.1 * 800
assert tracker.velocity.length() < 1.0
def test_jitter_is_smoothed() -> None:
rng = random.Random(0)
tracker = VelocityTracker(WINDOW, SMOOTHING)
tracker.reset(0.0, Vector2(0, 0))
for frame in range(1, 61):
t = frame / 60
noise = Vector2(rng.uniform(-3, 3), rng.uniform(-3, 3)) # zittrige Hand
tracker.add_sample(t, Vector2(400 * t, 0) + noise)
assert tracker.velocity.x == pytest.approx(400, rel=0.1)
assert abs(tracker.velocity.y) < 60
def test_samples_with_the_same_timestamp_replace_each_other() -> None:
tracker = VelocityTracker(WINDOW, SMOOTHING)
tracker.reset(0.0, Vector2(0, 0))
tracker.add_sample(0.0, Vector2(50, 0)) # gleicher Frame: keine Division durch 0
assert tracker.velocity == Vector2(0, 0)
tracker.add_sample(0.1, Vector2(50, 0))
assert tracker.velocity.x == 0 # die ersetzte Position ist der neue Bezugspunkt
def test_first_sample_without_reset_starts_a_measurement() -> None:
tracker = VelocityTracker(WINDOW, SMOOTHING)
tracker.add_sample(1.0, Vector2(5, 5))
assert tracker.velocity == Vector2(0, 0)
def test_invalid_parameters_are_rejected() -> None:
with pytest.raises(ValueError, match="positiv"):
VelocityTracker(0, SMOOTHING)
+1
View File
@@ -0,0 +1 @@
"""Tests der pygame-freien Physik-Schicht."""
+171
View File
@@ -0,0 +1,171 @@
"""Tests für Formen, Materialien und Körper (Masse, Trägheit, Impulse, Zustände)."""
import math
from collections.abc import Callable
import pytest
from sandbox.physics import Body, Box, Circle, HalfPlane, Material, Vector2
from sandbox.physics.vector import ZERO
from tests.helpers import material
def test_circle_area_and_moment_of_inertia() -> None:
circle = Circle(10)
assert circle.area() == pytest.approx(math.pi * 100)
assert circle.moment_of_inertia(mass=2) == pytest.approx(0.5 * 2 * 100)
assert circle.half_extents() == (10, 10)
def test_box_area_and_infinite_inertia() -> None:
box = Box(3, 2)
assert box.area() == 24
assert math.isinf(box.moment_of_inertia(mass=5))
assert box.half_extents() == (3, 2)
@pytest.mark.parametrize(
"factory",
[lambda: Circle(0), lambda: Circle(-1), lambda: Box(0, 1), lambda: Box(1, -2)],
)
def test_invalid_shapes_are_rejected(factory: Callable[[], object]) -> None:
with pytest.raises(ValueError, match="positiv"):
factory()
def test_half_plane_requires_unit_normal() -> None:
with pytest.raises(ValueError, match="Länge 1"):
HalfPlane(Vector2(0, 2), 0)
def test_half_plane_distance_and_contains() -> None:
floor = HalfPlane(Vector2(0, -1), -100) # frei ist y ≤ 100
assert floor.distance(Vector2(0, 90)) == pytest.approx(10)
assert floor.distance(Vector2(0, 110)) == pytest.approx(-10)
assert floor.contains(Vector2(0, 110))
assert not floor.contains(Vector2(0, 90))
assert floor.contains(Vector2(0, 90), tolerance=10)
assert math.isinf(floor.half_extents()[0])
def test_point_containment_with_tolerance() -> None:
circle = Body.circle(Vector2(100, 100), 10, material())
assert circle.contains_point(Vector2(107, 107))
assert not circle.contains_point(Vector2(110, 110))
assert circle.contains_point(Vector2(110, 110), tolerance=5)
box = Body.box(Vector2(0, 0), 10, 5, material())
assert box.contains_point(Vector2(10, -5))
assert not box.contains_point(Vector2(10.5, 0))
assert box.contains_point(Vector2(10.5, 0), tolerance=1)
def test_mass_is_area_times_density() -> None:
circle = Body.circle(Vector2(), 10, material(density=2))
assert circle.mass == pytest.approx(math.pi * 100 * 2)
assert circle.inv_mass == pytest.approx(1 / circle.mass)
box = Body.box(Vector2(), 10, 5, material(density=0.5))
assert box.mass == pytest.approx(20 * 10 * 0.5)
def test_bigger_bodies_are_heavier() -> None:
small = Body.circle(Vector2(), 10, material())
large = Body.circle(Vector2(), 20, material())
assert large.mass == pytest.approx(4 * small.mass) # Fläche wächst mit r²
def test_static_bodies_have_infinite_mass_and_never_move() -> None:
wall = Body.wall(Vector2(0, 1), 0, material())
assert wall.is_static
assert wall.inv_mass == 0
assert wall.inv_inertia == 0
assert math.isinf(wall.mass)
wall.apply_impulse(Vector2(100, 100))
assert wall.velocity == ZERO
assert wall.kinetic_energy() == 0
assert wall.momentum() == ZERO
def test_half_planes_cannot_be_dynamic() -> None:
with pytest.raises(ValueError, match="statisch"):
Body(HalfPlane(Vector2(0, 1), 0), Vector2(), material())
def test_dynamic_bodies_need_positive_density() -> None:
with pytest.raises(ValueError, match="Dichte"):
Body.circle(Vector2(), 10, material(density=0))
@pytest.mark.parametrize(
("density", "restitution", "friction"),
[(-1, 0.5, 0.5), (1, -0.1, 0.5), (1, 1.5, 0.5), (1, 0.5, -1)],
)
def test_invalid_materials_are_rejected(
density: float, restitution: float, friction: float
) -> None:
with pytest.raises(ValueError, match=r"Dichte|Restitution|Reibung"):
Material(density, restitution, friction)
def test_circles_can_rotate_but_boxes_cannot() -> None:
circle = Body.circle(Vector2(), 10, material())
box = Body(Box(10, 10), Vector2(), material(), angular_velocity=5)
assert circle.can_rotate
assert not box.can_rotate
assert box.angular_velocity == 0 # Drehung wird bei Rechtecken ignoriert
def test_off_center_impulse_spins_a_circle() -> None:
circle = Body.circle(Vector2(), 10, material())
# Stoß nach rechts an der Unterseite: erzeugt Vorwärtsbewegung und Drehung.
circle.apply_impulse(Vector2(circle.mass * 10, 0), offset=Vector2(0, 10))
assert circle.velocity.is_close(Vector2(10, 0))
expected_spin = circle.inv_inertia * Vector2(0, 10).cross(Vector2(circle.mass * 10, 0))
assert circle.angular_velocity == pytest.approx(expected_spin)
assert circle.angular_velocity < 0
def test_off_center_impulse_does_not_spin_a_box() -> None:
box = Body.box(Vector2(), 10, 10, material())
box.apply_impulse(Vector2(box.mass, 0), offset=Vector2(0, 10))
assert box.angular_velocity == 0
assert box.velocity.is_close(Vector2(1, 0))
def test_kinetic_energy_and_momentum() -> None:
circle = Body.circle(Vector2(), 10, material(), velocity=Vector2(3, 4))
circle.angular_velocity = 2
expected = 0.5 * circle.mass * 25 + 0.5 * circle.inertia * 4
assert circle.kinetic_energy() == pytest.approx(expected)
assert circle.momentum().is_close(Vector2(3, 4) * circle.mass)
def test_bounds() -> None:
assert Body.circle(Vector2(10, 20), 5, material()).bounds() == (5, 15, 15, 25)
assert Body.box(Vector2(10, 20), 4, 2, material()).bounds() == (6, 18, 14, 22)
def test_sleep_wake_and_teleport() -> None:
body = Body.circle(Vector2(0, 0), 10, material(), velocity=Vector2(5, 0))
body.angular_velocity = 1
body.sleep_time = 1
body.put_to_sleep()
assert body.sleeping
assert body.velocity == ZERO
assert body.angular_velocity == 0
body.teleport(Vector2(50, 60))
assert not body.sleeping
assert body.sleep_time == 0
assert body.position == body.previous_position == Vector2(50, 60)
def test_ids_are_unique() -> None:
bodies = [Body.circle(Vector2(), 10, material()) for _ in range(5)]
assert len({body.id for body in bodies}) == 5
def test_repr_mentions_id_and_sleep_state() -> None:
body = Body.circle(Vector2(1, 2), 10, material())
assert f"id={body.id}" in repr(body)
body.put_to_sleep()
assert "schläft" in repr(body)
+56
View File
@@ -0,0 +1,56 @@
"""Tests der Broadphase (Sweep-and-Prune)."""
import random
from sandbox.physics import Body, Vector2
from sandbox.physics.broadphase import find_candidate_pairs
from tests.helpers import material
def _random_scene(seed: int, count: int) -> list[Body]:
rng = random.Random(seed)
bodies = []
for _ in range(count):
position = Vector2(rng.uniform(0, 400), rng.uniform(0, 300))
if rng.random() < 0.5:
bodies.append(Body.circle(position, rng.uniform(5, 30), material()))
else:
bodies.append(Body.box(position, rng.uniform(5, 30), rng.uniform(5, 30), material()))
return bodies
def _overlap(a: Body, b: Body) -> bool:
a_min_x, a_min_y, a_max_x, a_max_y = a.bounds()
b_min_x, b_min_y, b_max_x, b_max_y = b.bounds()
return a_min_x <= b_max_x and b_min_x <= a_max_x and a_min_y <= b_max_y and b_min_y <= a_max_y
def test_matches_brute_force_on_random_scenes() -> None:
for seed in range(5):
bodies = _random_scene(seed, 60)
expected = {
(min(a.id, b.id), max(a.id, b.id))
for i, a in enumerate(bodies)
for b in bodies[i + 1 :]
if _overlap(a, b)
}
found = [(a.id, b.id) for a, b in find_candidate_pairs(bodies)]
assert len(found) == len(set(found)), "kein Paar doppelt"
assert set(found) == expected
def test_pairs_are_ordered_by_id() -> None:
bodies = _random_scene(7, 40)
for a, b in find_candidate_pairs(bodies):
assert a.id < b.id
def test_pairs_of_sleeping_bodies_are_skipped() -> None:
a = Body.circle(Vector2(0, 0), 10, material())
b = Body.circle(Vector2(15, 0), 10, material())
c = Body.circle(Vector2(30, 0), 10, material())
a.put_to_sleep()
b.put_to_sleep()
pairs = {(x.id, y.id) for x, y in find_candidate_pairs([a, b, c])}
assert (a.id, b.id) not in pairs # beide schlafen
assert (b.id, c.id) in pairs # schlafend + wach wird geprüft
+244
View File
@@ -0,0 +1,244 @@
"""Tests der Kollisionserkennung für alle Formpaarungen einschließlich Randfällen."""
import random
import pytest
from sandbox.physics import Body, Vector2, collide
from sandbox.physics.collision import (
COINCIDENT_NORMAL,
collide_box_half_plane,
collide_boxes,
collide_circle_box,
collide_circle_half_plane,
collide_circles,
)
from tests.helpers import material
def circle(x: float, y: float, radius: float) -> Body:
return Body.circle(Vector2(x, y), radius, material())
def box(x: float, y: float, half_width: float, half_height: float) -> Body:
return Body.box(Vector2(x, y), half_width, half_height, material())
def floor_at(y: float) -> Body:
return Body.wall(Vector2(0, -1), -y, material()) # frei ist alles oberhalb von y
# --------------------------------------------------------------------- Kreis–Kreis
def test_separated_circles_do_not_collide() -> None:
assert collide_circles(circle(0, 0, 10), circle(21, 0, 10)) is None
def test_touching_circles_form_a_contact_without_penetration() -> None:
contact = collide_circles(circle(0, 0, 10), circle(20, 0, 10))
assert contact is not None
assert contact.penetration == pytest.approx(0)
def test_overlapping_circles() -> None:
a = circle(0, 0, 10)
b = circle(15, 0, 10)
contact = collide_circles(a, b)
assert contact is not None
assert contact.body_a is a
assert contact.body_b is b
assert contact.normal.is_close(Vector2(1, 0))
assert contact.penetration == pytest.approx(5)
assert contact.point.is_close(Vector2(7.5, 0)) # Mitte des Überlappungsbereichs
def test_coincident_circles_use_a_fixed_fallback_normal() -> None:
contact = collide_circles(circle(5, 5, 10), circle(5, 5, 8))
assert contact is not None
assert contact.normal == COINCIDENT_NORMAL
assert contact.penetration == pytest.approx(18)
# --------------------------------------------------------------------- Kreis–Rechteck
def test_circle_touching_the_top_face_of_a_box() -> None:
contact = collide_circle_box(circle(0, -14, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis (oben) zur Box (unten)
assert contact.penetration == pytest.approx(1)
assert contact.point.is_close(Vector2(0, -10))
def test_circle_touching_the_right_face_of_a_box() -> None:
contact = collide_circle_box(circle(24, 0, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 0))
assert contact.penetration == pytest.approx(1)
def test_circle_touching_a_corner_gets_a_diagonal_normal() -> None:
contact = collide_circle_box(circle(23, -13, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 1).normalized())
assert contact.penetration == pytest.approx(5 - 18**0.5)
assert contact.point.is_close(Vector2(20, -10))
def test_circle_near_a_corner_but_outside_does_not_collide() -> None:
# Die umschließenden Boxen überlappen, der Kreis verfehlt die Ecke aber knapp.
assert collide_circle_box(circle(24, -14, 5), box(0, 0, 20, 10)) is None
def test_circle_center_inside_box_is_pushed_out_through_the_nearest_side_face() -> None:
contact = collide_circle_box(circle(15, 2, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 0)) # Kreis bewegt sich entlang −n nach rechts
assert contact.penetration == pytest.approx(5 + 5) # Radius + Abstand zur Kante
assert contact.point.is_close(Vector2(20, 2))
def test_circle_center_inside_box_is_pushed_out_through_the_nearest_top_face() -> None:
contact = collide_circle_box(circle(3, -8, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1)) # Kreis bewegt sich nach oben
assert contact.penetration == pytest.approx(5 + 2)
assert contact.point.is_close(Vector2(3, -10))
# --------------------------------------------------------------------- Rechteck–Rechteck
def test_boxes_are_separated_along_the_axis_of_least_overlap() -> None:
a = box(0, 0, 10, 10)
contact = collide_boxes(a, box(15, 5, 10, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(1, 0))
assert contact.penetration == pytest.approx(5)
assert contact.point.is_close(Vector2(7.5, 2.5)) # Mitte des Überlappungsrechtecks
contact = collide_boxes(a, box(5, -18, 10, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, -1))
assert contact.penetration == pytest.approx(2)
def test_equal_overlap_prefers_the_vertical_axis() -> None:
contact = collide_boxes(box(0, 0, 10, 10), box(15, 15, 10, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1))
@pytest.mark.parametrize(("x", "y"), [(21, 0), (0, 25), (-21, 5), (3, -20.5)])
def test_separated_boxes_do_not_collide(x: float, y: float) -> None:
assert collide_boxes(box(0, 0, 10, 10), box(x, y, 10, 10)) is None
# --------------------------------------------------------------------- Wände
def test_circle_resting_on_the_floor() -> None:
contact = collide_circle_half_plane(circle(50, 95, 10), floor_at(100))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis in die Wand
assert contact.penetration == pytest.approx(5)
assert contact.point.is_close(Vector2(50, 100))
def test_circle_above_the_floor_does_not_collide() -> None:
assert collide_circle_half_plane(circle(50, 80, 10), floor_at(100)) is None
def test_circle_completely_behind_the_wall_is_still_detected() -> None:
# Der "Tunnel"-Fall: Selbst ein Körper hinter der Wandlinie wird erkannt
# und in den freien Raum zurückgeschoben.
contact = collide_circle_half_plane(circle(50, 130, 10), floor_at(100))
assert contact is not None
assert contact.penetration == pytest.approx(40)
assert contact.normal.is_close(Vector2(0, 1))
def test_box_against_floor_and_side_wall() -> None:
contact = collide_box_half_plane(box(50, 95, 20, 10), floor_at(100))
assert contact is not None
assert contact.penetration == pytest.approx(5)
left_wall = Body.wall(Vector2(1, 0), 0, material())
contact = collide_box_half_plane(box(15, 50, 20, 10), left_wall)
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 0))
assert contact.penetration == pytest.approx(5)
assert collide_box_half_plane(box(25, 50, 20, 10), left_wall) is None
# --------------------------------------------------------------------- Zuordnung
def test_collide_orders_bodies_canonically() -> None:
c = circle(0, -14, 5)
b = box(0, 0, 20, 10)
for first, second in ((c, b), (b, c)):
contact = collide(first, second)
assert contact is not None
assert contact.body_a is c
assert contact.body_b is b
assert contact.normal.is_close(Vector2(0, 1))
wall = floor_at(100)
contact = collide(wall, circle(50, 95, 10))
assert contact is not None
assert contact.body_b is wall
def test_two_walls_never_collide() -> None:
assert collide(floor_at(100), floor_at(50)) is None
def test_contact_key_and_repr() -> None:
a = circle(0, 0, 10)
b = circle(15, 0, 10)
contact = collide(a, b)
assert contact is not None
assert contact.key == (a.id, b.id)
assert "penetration=5.000" in repr(contact)
# --------------------------------------------------------------------- Eigenschaften
def _random_body(rng: random.Random) -> Body:
x = rng.uniform(-40, 40)
y = rng.uniform(-40, 40)
if rng.random() < 0.5:
return circle(x, y, rng.uniform(5, 30))
return box(x, y, rng.uniform(5, 30), rng.uniform(5, 30))
def _random_wall(rng: random.Random) -> Body:
normal = rng.choice([Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)])
return Body.wall(normal, rng.uniform(-40, 40), material())
def test_random_contacts_are_consistent_and_resolvable() -> None:
"""Zufällige Kontakte sind in sich stimmig und lassen sich exakt auflösen.
Für viele Zufallspaare gilt: Die Normale ist ein Einheitsvektor, die Tiefe ist ≥ 0, und
verschiebt man Körper A um (Tiefe + ε) entgegen der Normalen, berühren sich die Körper
nicht mehr. Genau diese Information braucht die Kollisionsauflösung.
"""
rng = random.Random(1234)
checked = 0
for _ in range(3000):
first = _random_body(rng)
second = _random_wall(rng) if rng.random() < 0.25 else _random_body(rng)
contact = collide(first, second)
if contact is None:
continue
checked += 1
assert contact.normal.length() == pytest.approx(1)
assert contact.penetration >= 0
mover = contact.body_a
mover.position = mover.position - contact.normal * (contact.penetration + 1e-6)
assert collide(contact.body_a, contact.body_b) is None
assert checked > 500 # genug echte Kontakte geprüft
+105
View File
@@ -0,0 +1,105 @@
"""Tests des Schlafmodus (Inseln, Einschlafen, Aufwecken)."""
from dataclasses import replace
from sandbox.config import PHYSICS
from sandbox.physics import Body, Contact, Vector2, collide
from sandbox.physics.sleep import UnionFind, find_islands, update_sleep, wake_touched_bodies
from tests.helpers import material
def _circle(x: float, y: float = 0.0, radius: float = 10.0) -> Body:
return Body.circle(Vector2(x, y), radius, material())
def _contacts(*pairs: tuple[Body, Body]) -> list[Contact]:
contacts = [collide(a, b) for a, b in pairs]
assert all(contact is not None for contact in contacts)
return [contact for contact in contacts if contact is not None]
def test_union_find_merges_sets() -> None:
union_find = UnionFind([1, 2, 3, 4, 5])
union_find.union(1, 2)
union_find.union(3, 4)
union_find.union(2, 4)
assert union_find.find(1) == union_find.find(3) == union_find.find(4)
assert union_find.find(5) != union_find.find(1)
def test_touching_bodies_form_islands_but_walls_do_not_connect_them() -> None:
a, b, c, d = _circle(0), _circle(15), _circle(30), _circle(200)
floor = Body.wall(Vector2(0, -1), -5, material()) # berührt alle Kreise
contacts = _contacts((a, b), (b, c), (a, floor), (d, floor))
islands = find_islands([a, b, c, d], contacts)
assert [set(island) for island in islands] == [{a, b, c}, {d}]
def test_an_island_sleeps_only_when_all_its_bodies_rest() -> None:
config = replace(PHYSICS, time_to_sleep=0.5)
resting, moving = _circle(0), _circle(15)
moving.velocity = Vector2(50, 0)
contacts = _contacts((resting, moving))
for _ in range(100):
update_sleep([resting, moving], contacts, 0.01, config)
assert resting.sleep_time >= 0.5
assert not resting.sleeping # der bewegte Nachbar hält die Insel wach
moving.velocity = Vector2(0, 0)
for _ in range(60):
update_sleep([resting, moving], contacts, 0.01, config)
assert resting.sleeping
assert moving.sleeping
def test_waking_one_body_wakes_its_whole_island() -> None:
a, b, c, outsider = _circle(0), _circle(15), _circle(30), _circle(300)
island = [a, b, c]
for body in island:
body.put_to_sleep(island)
outsider.put_to_sleep()
c.wake()
assert not any(body.sleeping for body in island)
assert all(body.sleep_island is None for body in island)
assert outsider.sleeping
def test_touching_a_sleeper_wakes_and_reports_its_whole_island() -> None:
a, b, c = _circle(0), _circle(15), _circle(30)
island = [b, c]
for body in island:
body.put_to_sleep(island)
woken = wake_touched_bodies(_contacts((a, b)))
assert set(woken) == {b, c}
assert not c.sleeping
def test_held_bodies_keep_their_island_awake() -> None:
held, other = _circle(0), _circle(15)
held.held = True
contacts = _contacts((held, other))
for _ in range(200):
update_sleep([held, other], contacts, 0.01, PHYSICS)
assert not held.sleeping
assert not other.sleeping
assert held.sleep_time == 0
def test_sleepers_touched_by_awake_bodies_are_woken_but_not_by_walls() -> None:
sleeper, awake = _circle(0), _circle(15)
sleeper.put_to_sleep()
floor = Body.wall(Vector2(0, -1), -5, material())
lonely = _circle(300)
lonely.put_to_sleep()
both_asleep = _circle(500)
both_asleep.put_to_sleep()
neighbour = _circle(515)
neighbour.put_to_sleep()
woken = wake_touched_bodies(
_contacts((sleeper, awake), (lonely, floor), (both_asleep, neighbour))
)
assert woken == [sleeper]
assert not sleeper.sleeping
assert lonely.sleeping
assert both_asleep.sleeping
+186
View File
@@ -0,0 +1,186 @@
"""Tests der Kollisionsauflösung: Stöße, Restitution, Reibung, Rollen, Positionskorrektur.
Die Erwartungswerte stammen aus der klassischen Mechanik (Impuls- und Energieerhaltung,
Coulomb-Reibung, Rollbedingung v = ω·r) und nicht aus der Implementierung selbst.
"""
import math
import pytest
from sandbox.config import PHYSICS
from tests.helpers import (
add_box,
add_circle,
make_world,
simulate,
total_kinetic_energy,
total_momentum,
)
GRAVITY = PHYSICS.gravity[1]
FLOOR = 700.0
def _head_on_pair(restitution: float) -> tuple:
"""Zwei Kreise ohne Schwerkraft, Massenverhältnis 1:3, frontal aufeinander zu."""
world = make_world(gravity=(0.0, 0.0))
light = add_circle(world, 300, 350, 20, velocity=(200, 0), restitution=restitution)
heavy = add_circle(
world, 500, 350, 20, velocity=(-100, 0), density=3.0, restitution=restitution
)
return world, light, heavy
def test_elastic_collision_conserves_momentum_and_energy() -> None:
world, light, heavy = _head_on_pair(restitution=1.0)
momentum_before = total_momentum(world.bodies)
energy_before = total_kinetic_energy(world.bodies)
simulate(world, 1.0) # Stoß bei t ≈ 0,53 s, danach noch weit weg von den Wänden
assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6)
assert total_kinetic_energy(world.bodies) == pytest.approx(energy_before, rel=1e-9)
# Lehrbuchformel für den 1D-Stoß: v1' = ((m1 − m2)·v1 + 2·m2·v2) / (m1 + m2)
m1, m2, v1, v2 = light.mass, heavy.mass, 200.0, -100.0
assert light.velocity.x == pytest.approx(((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2))
assert heavy.velocity.x == pytest.approx(((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2))
assert light.velocity.x == pytest.approx(-250)
assert heavy.velocity.x == pytest.approx(50)
def test_equal_masses_exchange_velocities_in_an_elastic_collision() -> None:
world = make_world(gravity=(0.0, 0.0))
a = add_circle(world, 300, 350, 20, velocity=(150, 0), restitution=1.0)
b = add_circle(world, 400, 350, 20, restitution=1.0)
simulate(world, 0.8)
assert a.velocity.x == pytest.approx(0, abs=1e-9)
assert b.velocity.x == pytest.approx(150)
def test_perfectly_inelastic_collision_leaves_a_common_velocity() -> None:
world, light, heavy = _head_on_pair(restitution=0.0)
momentum_before = total_momentum(world.bodies)
energy_before = total_kinetic_energy(world.bodies)
simulate(world, 1.0)
common = momentum_before.x / (light.mass + heavy.mass) # = −25 px/s
assert light.velocity.x == pytest.approx(common)
assert heavy.velocity.x == pytest.approx(common)
assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6)
assert total_kinetic_energy(world.bodies) < energy_before
def test_restitution_below_one_loses_energy_as_predicted() -> None:
world, light, heavy = _head_on_pair(restitution=0.5)
energy_before = total_kinetic_energy(world.bodies)
simulate(world, 1.0)
# Newtons Stoßgesetz: Trenngeschwindigkeit = e · Aufprallgeschwindigkeit (300 px/s).
assert heavy.velocity.x - light.velocity.x == pytest.approx(0.5 * 300)
# Energieverlust beim zentralen Stoß: ΔE = ½·μ·(1 − e²)·v_rel² mit μ = m1·m2/(m1 + m2).
reduced_mass = light.mass * heavy.mass / (light.mass + heavy.mass)
expected_loss = 0.5 * reduced_mass * (1 - 0.5**2) * 300**2
energy_loss = energy_before - total_kinetic_energy(world.bodies)
assert energy_loss == pytest.approx(expected_loss, rel=1e-6)
def test_slow_impacts_below_the_threshold_do_not_bounce() -> None:
world = make_world(gravity=(0.0, 0.0))
approach = 0.8 * PHYSICS.restitution_threshold
a = add_circle(world, 300, 350, 20, velocity=(approach, 0), restitution=1.0)
b = add_circle(world, 345, 350, 20, restitution=1.0)
simulate(world, 0.5)
assert b.velocity.x - a.velocity.x == pytest.approx(0, abs=1e-9)
def test_frictionless_oblique_impact_keeps_the_tangential_velocity() -> None:
world = make_world(gravity=(0.0, 0.0), wall_friction=0.0)
ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), restitution=1.0, friction=0.0)
simulate(world, 0.4) # trifft die linke Wand bei t ≈ 0,27 s
assert ball.velocity.x == pytest.approx(300)
assert ball.velocity.y == pytest.approx(120)
assert ball.angular_velocity == 0 # Normalimpulse erzeugen kein Drehmoment
def test_friction_on_an_oblique_impact_makes_a_ball_spin() -> None:
world = make_world(gravity=(0.0, 0.0))
ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), friction=0.8)
simulate(world, 0.4)
assert ball.angular_velocity != 0
assert abs(ball.velocity.y) < 120 # Reibung hat die Tangentialgeschwindigkeit gebremst
def test_sliding_box_decelerates_with_coulomb_friction() -> None:
world = make_world(wall_friction=0.6)
box = add_box(world, 200, FLOOR - 20, 20, 20, velocity=(300, 0), friction=0.4)
simulate(world, 1.5)
mu = math.sqrt(0.4 * 0.6) # gemischter Reibwert Box/Boden
expected_distance = 300**2 / (2 * mu * GRAVITY) # s = v² / (2·μ·g)
assert box.position.x - 200 == pytest.approx(expected_distance, rel=0.03)
assert box.velocity.x == pytest.approx(0, abs=1e-6)
def test_sliding_disk_starts_rolling_at_two_thirds_of_its_speed() -> None:
"""Eine rutschende Scheibe geht ins Rollen über und behält dabei ⅔ ihres Tempos.
Reibung bremst die Scheibe (I = ½·m·r²) und versetzt sie in Drehung, bis v = ω·r gilt.
Drehimpulserhaltung um den Kontaktpunkt liefert dann v = v0 / (1 + I/(m·r²)) = ⅔·v0 –
unabhängig vom Reibwert.
"""
world = make_world(rolling_resistance=0.0, sleep_enabled=False)
radius = 20
disk = add_circle(world, 200, FLOOR - radius, radius, velocity=(300, 0), friction=0.5)
simulate(world, 1.0)
assert disk.velocity.x == pytest.approx(200, rel=0.01)
assert disk.angular_velocity * radius == pytest.approx(disk.velocity.x, rel=0.01)
def test_rolling_resistance_decelerates_a_rolling_ball() -> None:
"""Rollwiderstand: Moment μ_r·N·r ⇒ Verzögerung a = μ_r·g / (1 + I/(m·r²)) = μ_r·g/1,5."""
world = make_world(sleep_enabled=False)
radius = 25
ball = add_circle(
world, 200, FLOOR - radius, radius, velocity=(200, 0), angular_velocity=200 / radius
)
simulate(world, 1.0)
expected_deceleration = PHYSICS.rolling_resistance * GRAVITY / 1.5
assert 200 - ball.velocity.x == pytest.approx(expected_deceleration, rel=0.02)
def test_resting_contact_carries_exactly_the_weight() -> None:
world = make_world(sleep_enabled=False)
box = add_box(world, 300, FLOOR - 20, 30, 20)
simulate(world, 0.5)
(contact,) = world.contacts
# Pro Schritt muss der Boden den Impuls m·g·dt aufbringen, damit die Box liegen bleibt.
assert contact.normal_impulse == pytest.approx(box.mass * GRAVITY * PHYSICS.time_step)
assert contact.tangent_impulse == pytest.approx(0, abs=1e-9)
def test_impulses_respect_non_penetration_and_coulomb_bounds() -> None:
world = make_world()
for i in range(12):
add_circle(world, 300 + 25 * (i % 4), 200 + 30 * (i // 4), 14, velocity=(40 * i, 0))
add_box(world, 600 + 30 * (i % 3), 150 + 35 * (i // 3), 14, 16)
for _ in range(240):
world.step(PHYSICS.time_step)
for contact in world.contacts:
assert contact.normal_impulse >= 0
mu = math.sqrt(contact.body_a.material.friction * contact.body_b.material.friction)
assert abs(contact.tangent_impulse) <= mu * contact.normal_impulse + 1e-9
def test_position_correction_separates_without_adding_velocity() -> None:
world = make_world(gravity=(0.0, 0.0), sleep_enabled=False)
a = add_circle(world, 500, 350, 20)
b = add_circle(world, 510, 350, 20) # 30 px Überlappung
simulate(world, 1.0)
assert a.position.distance_to(b.position) >= 40 - PHYSICS.penetration_slop - 1e-9
assert a.velocity.length() == 0
assert b.velocity.length() == 0
def test_warm_starting_can_be_disabled() -> None:
world = make_world(warm_starting=False)
box = add_box(world, 300, FLOOR - 20, 30, 20)
simulate(world, 2.0)
assert box.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop)
+57
View File
@@ -0,0 +1,57 @@
"""Tests des festen Zeitschritts (Akkumulator)."""
import pytest
from sandbox.physics import FixedTimestep
STEP = 1 / 120
def test_a_60_fps_frame_runs_two_physics_steps() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
assert timestep.advance(1 / 60) == 2
assert timestep.alpha == pytest.approx(0, abs=1e-6)
def test_short_frames_accumulate() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
assert timestep.advance(1 / 240) == 0
assert timestep.alpha == pytest.approx(0.5)
assert timestep.advance(1 / 240) == 1
def test_no_steps_are_lost_through_rounding() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
total = sum(timestep.advance(1 / 60) for _ in range(1000))
assert total == 2000
def test_alpha_stays_in_the_unit_interval() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
for frame in (0.003, 0.011, 0.017, 0.0001, 0.02):
timestep.advance(frame)
assert 0 <= timestep.alpha < 1
def test_huge_frames_are_capped_to_avoid_the_spiral_of_death() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=5)
assert timestep.advance(2.0) == 5
assert timestep.alpha == 0 # der Rückstand wurde verworfen
assert timestep.advance(1 / 60) == 2
def test_reset_discards_accumulated_time() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
timestep.advance(STEP * 0.9)
timestep.reset()
assert timestep.alpha == 0
assert timestep.advance(STEP * 0.5) == 0
def test_invalid_arguments_are_rejected() -> None:
with pytest.raises(ValueError, match="positiv"):
FixedTimestep(0, 8)
with pytest.raises(ValueError, match="mindestens"):
FixedTimestep(STEP, 0)
with pytest.raises(ValueError, match="negativ"):
FixedTimestep(STEP, 8).advance(-0.1)
+127
View File
@@ -0,0 +1,127 @@
"""Tests der eigenen Vektorklasse."""
import copy
import math
import pickle
import pytest
from sandbox.physics.vector import ZERO, Vector2
def test_addition_and_subtraction() -> None:
assert Vector2(1, 2) + Vector2(3, -5) == Vector2(4, -3)
assert Vector2(1, 2) - Vector2(3, -5) == Vector2(-2, 7)
def test_scaling_from_both_sides_and_division() -> None:
assert Vector2(1.5, -2) * 2 == Vector2(3, -4)
assert 2 * Vector2(1.5, -2) == Vector2(3, -4)
assert Vector2(3, -4) / 2 == Vector2(1.5, -2)
def test_negation() -> None:
assert -Vector2(1, -2) == Vector2(-1, 2)
def test_dot_product() -> None:
assert Vector2(1, 2).dot(Vector2(3, 4)) == 11
assert Vector2(1, 0).dot(Vector2(0, 5)) == 0 # orthogonal
def test_cross_product_sign_and_value() -> None:
assert Vector2(1, 0).cross(Vector2(0, 1)) == 1
assert Vector2(0, 1).cross(Vector2(1, 0)) == -1
assert Vector2(2, 3).cross(Vector2(4, 6)) == 0 # parallel
def test_length_and_length_squared() -> None:
v = Vector2(3, 4)
assert v.length() == 5
assert v.length_squared() == 25
def test_normalized_has_unit_length_and_same_direction() -> None:
n = Vector2(3, 4).normalized()
assert n.length() == pytest.approx(1.0)
assert n.is_close(Vector2(0.6, 0.8))
def test_normalizing_the_zero_vector_returns_zero_instead_of_nan() -> None:
assert Vector2(0, 0).normalized() == ZERO
def test_perpendicular_is_rotated_by_plus_90_degrees() -> None:
v = Vector2(2, 1)
p = v.perpendicular()
assert p == Vector2(-1, 2)
assert v.dot(p) == 0
assert v.cross(p) > 0
def test_distance() -> None:
assert Vector2(1, 1).distance_to(Vector2(4, 5)) == 5
def test_clamped_limits_only_long_vectors() -> None:
short = Vector2(1, 1)
assert short.clamped(10) is short
clamped = Vector2(30, 40).clamped(10)
assert clamped.length() == pytest.approx(10)
assert clamped.is_close(Vector2(6, 8))
def test_lerp() -> None:
a = Vector2(0, 10)
b = Vector2(10, 20)
assert a.lerp(b, 0) == a
assert a.lerp(b, 1) == b
assert a.lerp(b, 0.5) == Vector2(5, 15)
def test_is_finite() -> None:
assert Vector2(1, 2).is_finite()
assert not Vector2(math.nan, 0).is_finite()
assert not Vector2(0, math.inf).is_finite()
def test_is_close_uses_absolute_tolerance() -> None:
assert Vector2(1, 1).is_close(Vector2(1 + 1e-12, 1))
assert not Vector2(1, 1).is_close(Vector2(1.1, 1))
assert Vector2(1, 1).is_close(Vector2(1.1, 1), tolerance=0.2)
def test_equality_hash_and_comparison_with_other_types() -> None:
assert Vector2(1, 2) == Vector2(1.0, 2.0)
assert Vector2(1, 2) != Vector2(2, 1)
assert Vector2(1, 2) != (1, 2)
assert len({Vector2(1, 2), Vector2(1, 2), Vector2(2, 1)}) == 2
def test_vectors_are_immutable() -> None:
v = Vector2(1, 2)
with pytest.raises(AttributeError):
v.x = 5 # type: ignore[misc]
with pytest.raises(AttributeError):
del v.y
assert v == Vector2(1, 2)
def test_operations_do_not_modify_operands() -> None:
a = Vector2(1, 2)
b = Vector2(3, 4)
_ = (a + b, a - b, a * 3, a / 2, -a, a.normalized(), a.clamped(0.1), a.lerp(b, 0.5))
assert a == Vector2(1, 2)
assert b == Vector2(3, 4)
def test_copy_and_pickle_round_trip() -> None:
v = Vector2(1.5, -2.5)
assert copy.copy(v) == v
assert copy.deepcopy(v) == v
assert pickle.loads(pickle.dumps(v)) == v
def test_repr_and_tuple() -> None:
assert repr(Vector2(1.5, 2)) == "Vector2(1.5, 2)"
assert Vector2(1.5, 2).to_tuple() == (1.5, 2)
+446
View File
@@ -0,0 +1,446 @@
"""Szenario-Tests der Physikwelt: Fallen, Aufprallen, Stapeln, Ruhen, Grenzen, Stabilität."""
import itertools
import random
import pytest
from sandbox.config import MIN_BODY_SIZE, PHYSICS, TIME_STEP
from sandbox.physics import Body, Vector2, World
from tests.helpers import (
WORLD_HEIGHT,
WORLD_WIDTH,
add_box,
add_circle,
make_world,
material,
simulate,
total_kinetic_energy,
)
GRAVITY = PHYSICS.gravity[1]
FLOOR = WORLD_HEIGHT
#: Geduldete Rest-Überlappung ruhender Körper: die Toleranz ``penetration_slop`` plus
#: ein tausendstel Pixel für die nie ganz auskonvergierte Iteration.
MAX_RESTING_OVERLAP = PHYSICS.penetration_slop + 1e-3
# --------------------------------------------------------------------- Fallen und Aufprallen
def test_free_fall_matches_half_g_t_squared() -> None:
world = make_world(height=2000)
ball = add_circle(world, 500, 100, 10)
steps = 60
simulate(world, steps * TIME_STEP)
t = steps * TIME_STEP
fallen = ball.position.y - 100
assert fallen == pytest.approx(0.5 * GRAVITY * t * t, rel=0.02)
# Semi-impliziter Euler exakt: v_k = k·g·dt und s_n = g·dt²·n·(n+1)/2.
assert ball.velocity.y == pytest.approx(GRAVITY * t)
assert fallen == pytest.approx(GRAVITY * TIME_STEP**2 * steps * (steps + 1) / 2)
def test_bodies_without_gravity_stay_put() -> None:
world = make_world()
world.gravity_enabled = False
ball = add_circle(world, 500, 100, 10)
simulate(world, 1.0)
assert ball.position == Vector2(500, 100)
assert world.gravity == Vector2(0, 0)
def test_fallen_ball_comes_to_rest_on_the_floor_and_never_leaves_the_world() -> None:
world = make_world()
radius = 20
ball = add_circle(world, 500, 100, radius, restitution=0.6)
lowest = 0.0
for _ in range(round(8 / TIME_STEP)):
world.step(TIME_STEP)
lowest = max(lowest, ball.position.y + radius)
assert ball.position.x - radius >= 0
assert ball.position.x + radius <= WORLD_WIDTH
# Tiefer als die Notbremse erlaubt, sinkt der Ball nie in den Boden ein.
assert lowest <= FLOOR + PHYSICS.max_wall_penetration + 1e-9
assert ball.sleeping
assert ball.velocity == Vector2(0, 0)
assert ball.position.y == pytest.approx(FLOOR - radius, abs=PHYSICS.penetration_slop)
def _first_bounce_apex(restitution: float) -> tuple[float, float]:
"""Lässt einen Ball fallen und liefert (Fallhöhe, Steighöhe nach dem 1. Aufprall)."""
world = make_world()
radius = 20
start_y = 200.0
ball = add_circle(world, 500, start_y, radius, restitution=restitution)
resting_y = FLOOR - radius
bounced = False
apex = resting_y
for _ in range(round(4 / TIME_STEP)):
world.step(TIME_STEP)
if ball.velocity.y < 0:
bounced = True
apex = min(apex, ball.position.y)
elif bounced:
break # Scheitelpunkt überschritten
return resting_y - start_y, resting_y - apex
def test_bounce_height_follows_the_restitution_squared() -> None:
# Energieerhaltung beim Flug: h' / h = (v'/v)² = e².
drop, rise = _first_bounce_apex(restitution=0.8)
assert rise < drop # Energie geht verloren
assert rise == pytest.approx(0.8**2 * drop, rel=0.01)
def test_perfectly_elastic_ball_keeps_its_bounce_height() -> None:
"""Ein vollkommen elastischer Ball springt immer wieder genau auf seine Fallhöhe.
Er darf nie höher springen – sonst entstünde Energie aus dem Nichts, etwa durch die im
Aufprallschritt schon addierte Schwerkraft.
"""
world = make_world()
radius = 20
start_y = 250.0
ball = add_circle(world, 500, start_y, radius, restitution=1.0)
apexes = []
rising = False
for _ in range(round(8 / TIME_STEP)):
world.step(TIME_STEP)
if ball.velocity.y < 0:
rising = True
elif rising:
apexes.append(ball.position.y)
rising = False
assert len(apexes) >= 4
for apex in apexes:
assert apex == pytest.approx(start_y, abs=2.0) # 2 px bei 430 px Fallhöhe
def test_ball_without_restitution_does_not_bounce() -> None:
drop, rise = _first_bounce_apex(restitution=0.0)
assert drop > 0
assert rise == pytest.approx(0, abs=1.0)
def test_total_energy_never_increases_without_external_forces() -> None:
world = make_world(gravity=(0.0, 0.0))
rng = random.Random(42)
for _ in range(30):
velocity = (rng.uniform(-300, 300), rng.uniform(-300, 300))
x, y = rng.uniform(60, 940), rng.uniform(60, 640)
if rng.random() < 0.5:
add_circle(world, x, y, rng.uniform(12, 35), velocity=velocity)
else:
add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35), velocity=velocity)
energy = total_kinetic_energy(world.bodies)
for _ in range(round(5 / TIME_STEP)):
world.step(TIME_STEP)
new_energy = total_kinetic_energy(world.bodies)
assert new_energy <= energy * (1 + 1e-9) + 1e-6
energy = new_energy
# --------------------------------------------------------------------- Überlappen und Stapeln
def test_overlapping_bodies_are_pushed_apart() -> None:
world = make_world()
bottom = add_box(world, 500, FLOOR - 30, 40, 30)
top = add_box(world, 510, FLOOR - 70, 30, 30) # steckt 20 px in der unteren Box
simulate(world, 2.0)
gap = (bottom.position.y - 30) - (top.position.y + 30)
assert gap >= -MAX_RESTING_OVERLAP
def test_circle_spawned_inside_a_box_pops_out() -> None:
world = make_world()
base = add_box(world, 500, FLOOR - 40, 60, 40)
ball = add_circle(world, 520, FLOOR - 50, 15) # Mittelpunkt liegt in der Box
simulate(world, 2.0)
assert ball.position.y + 15 <= base.position.y - 40 + MAX_RESTING_OVERLAP
def test_box_stack_is_stable_and_falls_asleep() -> None:
world = make_world()
half_height = 20
boxes = [
add_box(world, 500, FLOOR - half_height - i * 2 * half_height, 30, half_height)
for i in range(6)
]
simulate(world, 5.0)
for lower, upper in itertools.pairwise(boxes):
assert upper.position.x == pytest.approx(500, abs=1e-6) # kein seitliches Wandern
gap = (lower.position.y - half_height) - (upper.position.y + half_height)
assert -MAX_RESTING_OVERLAP <= gap <= 1e-9
assert all(box.sleeping for box in boxes)
def test_pile_of_mixed_bodies_settles_completely() -> None:
world = make_world()
rng = random.Random(3)
for _ in range(60):
x, y = rng.uniform(60, 940), rng.uniform(40, 500)
if rng.random() < 0.5:
add_circle(world, x, y, rng.uniform(12, 35))
else:
add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35))
simulate(world, 8.0)
assert all(body.sleeping for body in world.bodies)
# --------------------------------------------------------------------- Geschwindigkeit und Tunneln
def test_speed_is_clamped_to_the_maximum() -> None:
world = make_world(gravity=(0.0, 0.0))
ball = add_circle(world, 500, 350, 20, velocity=(50_000, 0))
world.step(TIME_STEP)
assert ball.velocity.length() == pytest.approx(PHYSICS.max_speed)
def test_spin_is_clamped_to_the_maximum_in_both_directions() -> None:
world = make_world(gravity=(0.0, 0.0))
clockwise = add_circle(world, 300, 350, 20, angular_velocity=10_000)
counter = add_circle(world, 600, 350, 20, angular_velocity=-10_000)
world.step(TIME_STEP)
assert clockwise.angular_velocity == pytest.approx(PHYSICS.max_angular_speed)
assert counter.angular_velocity == pytest.approx(-PHYSICS.max_angular_speed)
def test_emergency_brake_never_interferes_with_free_flight() -> None:
# Die Notbremse muss über der größten Eindringtiefe pro Schritt liegen, sonst würde sie
# normale Aufpralle verfälschen (Positionen verschieben und Energie hinzufügen).
assert PHYSICS.max_wall_penetration > PHYSICS.max_speed * PHYSICS.time_step
def test_emergency_brake_limits_deep_wall_penetration() -> None:
world = make_world(gravity=(0.0, 0.0))
box = add_box(world, -10, 350, 20, 20) # steckt 30 px in der linken Wand
world.step(TIME_STEP)
assert box.bounds()[0] >= -PHYSICS.max_wall_penetration - 1e-9
simulate(world, 0.5)
assert box.bounds()[0] >= -MAX_RESTING_OVERLAP
def test_fast_small_ball_does_not_tunnel_through_a_wall() -> None:
world = make_world(gravity=(0.0, 0.0))
radius = MIN_BODY_SIZE
ball = add_circle(world, 500, 350, radius, velocity=(-50_000, -30_000), restitution=1.0)
for _ in range(round(3 / TIME_STEP)):
world.step(TIME_STEP)
allowed = PHYSICS.max_wall_penetration + 1e-9
assert -allowed <= ball.position.x - radius
assert ball.position.x + radius <= WORLD_WIDTH + allowed
assert -allowed <= ball.position.y - radius
assert ball.position.y + radius <= WORLD_HEIGHT + allowed
def test_two_smallest_bodies_at_max_speed_do_not_pass_through_each_other() -> None:
world = make_world(gravity=(0.0, 0.0))
radius = MIN_BODY_SIZE
speed = PHYSICS.max_speed
left = add_circle(world, 300, 350, radius, velocity=(speed, 0), restitution=1.0)
right = add_circle(world, 700, 350, radius, velocity=(-speed, 0), restitution=1.0)
simulate(world, 0.25) # Zusammenstoß nach ≈ 0,15 s
assert left.position.x < right.position.x
assert left.velocity.x < 0 < right.velocity.x
# --------------------------------------------------------------------- Schlafmodus
def test_sleeping_body_is_woken_by_an_impact() -> None:
world = make_world()
resting = add_box(world, 500, FLOOR - 20, 40, 20)
simulate(world, 1.5)
assert resting.sleeping
falling = add_circle(world, 500, 600, 15) # trifft die Box nach ≈ 0,3 s
simulate(world, 0.4)
assert not falling.sleeping
assert not resting.sleeping
def test_removing_a_support_wakes_the_body_on_top() -> None:
world = make_world()
support = add_box(world, 500, FLOOR - 20, 40, 20)
load = add_box(world, 500, FLOOR - 60, 30, 20)
simulate(world, 2.0)
assert support.sleeping
assert load.sleeping
world.remove_body(support)
assert not load.sleeping
simulate(world, 1.5)
assert load.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop)
def test_removing_a_body_only_wakes_sleeping_neighbours() -> None:
world = make_world()
removed = add_box(world, 300, FLOOR - 20, 40, 20)
neighbour = add_box(world, 360, FLOOR - 20, 20, 20)
far_away = add_box(world, 800, FLOOR - 20, 20, 20)
simulate(world, 2.0)
awake = add_circle(world, 300, 300, 15) # fällt noch, ist also wach
world.remove_body(removed)
assert not neighbour.sleeping
assert far_away.sleeping # weit weg: bleibt ungestört liegen
assert not awake.sleeping
assert removed not in world.bodies
def _is_supported(body: Body, world: World) -> bool:
"""Liegt der Körper auf dem Boden oder auf einem tiefer liegenden Körper auf?"""
min_x, _, max_x, max_y = body.bounds()
if max_y >= world.height - 1.0:
return True
for other in world.bodies:
if other is body or other.position.y <= body.position.y:
continue # nur Körper darunter können tragen
other_min_x, other_min_y, other_max_x, _ = other.bounds()
overlaps_horizontally = other_min_x <= max_x and min_x <= other_max_x
if overlaps_horizontally and other_min_y <= max_y + 1.0:
return True
return False
def test_moving_the_base_of_a_sleeping_stack_wakes_the_whole_stack() -> None:
"""Ein schlafender Stapel verliert seine Basis – die Kisten darüber fallen.
Die unterste Kiste wird (wie beim Ziehen im Pausenmodus) ohne Physikschritt weggesetzt.
Die Kisten darüber dürfen danach nicht in der Luft weiterschlafen.
"""
world = make_world()
stack = [add_box(world, 500, FLOOR - 20 - 40 * i, 30, 20) for i in range(3)]
simulate(world, 2.0)
assert all(box.sleeping for box in stack)
stack[0].teleport(Vector2(150, FLOOR - 20))
assert not any(box.sleeping for box in stack)
simulate(world, 2.0)
assert stack[1].position.y == pytest.approx(FLOOR - 20, abs=MAX_RESTING_OVERLAP)
assert stack[2].position.y == pytest.approx(FLOOR - 60, abs=2 * MAX_RESTING_OVERLAP)
def test_knocking_away_a_support_does_not_leave_bodies_floating() -> None:
"""Wird die Unterlage eines schlafenden Körpers weggestoßen, fällt er herunter.
Die flache Box verliert den Kontakt zur außermittig liegenden Kugel schon im ersten
Schritt nach dem Treffer. Ohne gemeinsames Wecken der Insel bliebe die Kugel schlafend
in der Luft hängen.
"""
world = make_world()
base = add_box(world, 500, FLOOR - 12, 40, 12) # flache Box am Boden
ball = add_circle(world, 530, FLOOR - 24 - 15, 15) # liegt 30 px außermittig darauf
simulate(world, 2.0)
assert base.sleeping
assert ball.sleeping
# Schwerer, flacher Rammbock trifft nur die Box, nicht die Kugel.
add_box(world, 800, FLOOR - 10, 20, 10, velocity=(-PHYSICS.max_speed, 0), density=5.0)
simulate(world, 2.0)
assert abs(ball.position.x - base.position.x) > 40 + 15 # Box ist unter der Kugel weg
assert ball.position.y == pytest.approx(FLOOR - 15, abs=MAX_RESTING_OVERLAP)
for body in world.bodies:
if body.sleeping:
assert _is_supported(body, world), f"{body} schläft in der Luft"
def test_toggling_gravity_wakes_everything() -> None:
world = make_world()
box = add_box(world, 500, FLOOR - 20, 40, 20)
simulate(world, 1.5)
assert box.sleeping
world.gravity_enabled = False
assert not box.sleeping
world.gravity_enabled = False # keine Änderung, kein erneutes Wecken nötig
assert not world.gravity_enabled
def test_held_bodies_ignore_gravity_and_never_sleep() -> None:
world = make_world()
ball = add_circle(world, 500, 300, 20)
ball.held = True
simulate(world, 2.0)
assert ball.position == Vector2(500, 300)
assert not ball.sleeping
def test_sleeping_can_be_disabled() -> None:
world = make_world(sleep_enabled=False)
box = add_box(world, 500, FLOOR - 20, 40, 20)
simulate(world, 3.0)
assert not box.sleeping
# --------------------------------------------------------------------- Verwaltung
def test_world_rejects_invalid_sizes_and_static_bodies() -> None:
with pytest.raises(ValueError, match="positive Größe"):
World(0, 100, PHYSICS)
world = make_world()
with pytest.raises(ValueError, match="Wände"):
world.add_body(Body.wall(Vector2(0, 1), 0, material()))
def test_body_at_returns_the_topmost_body() -> None:
world = make_world()
lower = add_box(world, 100, 100, 30, 30)
upper = add_circle(world, 110, 100, 20)
assert world.body_at(Vector2(110, 100)) is upper
assert world.body_at(Vector2(80, 80)) is lower
assert world.body_at(Vector2(300, 300)) is None
assert world.body_at(Vector2(133, 100), tolerance=5) is upper
def test_clamp_inside_keeps_the_whole_body_in_the_world() -> None:
world = make_world()
assert world.clamp_inside(Vector2(-50, 900), (20, 10)) == Vector2(20, WORLD_HEIGHT - 10)
inside = Vector2(300, 300)
assert world.clamp_inside(inside, (20, 10)) is inside
def test_clear_removes_all_bodies() -> None:
world = make_world()
for i in range(5):
add_circle(world, 100 + 50 * i, 600, 20)
simulate(world, 0.2)
world.clear()
assert world.bodies == []
assert world.contacts == []
simulate(world, 0.1) # auch eine leere Welt lässt sich weiter simulieren
def test_simulation_is_deterministic() -> None:
def run() -> list[tuple[float, float]]:
world = make_world()
rng = random.Random(5)
for _ in range(25):
add_circle(world, rng.uniform(50, 950), rng.uniform(50, 400), rng.uniform(12, 30))
simulate(world, 2.0)
return [body.position.to_tuple() for body in world.bodies]
assert run() == run()
@pytest.mark.slow
def test_hundred_bodies_stay_stable_for_a_thousand_steps() -> None:
world = make_world()
rng = random.Random(99)
for _ in range(100):
x, y = rng.uniform(40, 960), rng.uniform(40, 660)
velocity = (rng.uniform(-600, 600), rng.uniform(-600, 600))
if rng.random() < 0.5:
add_circle(world, x, y, rng.uniform(12, 30), velocity=velocity)
else:
add_box(world, x, y, rng.uniform(12, 30), rng.uniform(12, 30), velocity=velocity)
allowed = PHYSICS.max_wall_penetration + 1e-6
for _ in range(1000):
world.step(TIME_STEP)
for body in world.bodies:
assert body.position.is_finite()
assert body.velocity.is_finite()
assert body.velocity.length() <= PHYSICS.max_speed + 1e-6
min_x, min_y, max_x, max_y = body.bounds()
assert min_x >= -allowed and max_x <= WORLD_WIDTH + allowed
assert min_y >= -allowed and max_y <= WORLD_HEIGHT + allowed
+60
View File
@@ -0,0 +1,60 @@
"""Architektur-Tests: Die Physik-Schicht bleibt frei von pygame und fremden Bibliotheken.
Anforderung: Die gesamte Physik darf nur das Standardmodul ``math`` verwenden
(zusätzlich ``typing``/``dataclasses`` für Typangaben und Datenstrukturen). Diese Tests
prüfen das statisch über den Syntaxbaum und zur Laufzeit in einem frischen Interpreter.
"""
import ast
import os
import subprocess
import sys
from pathlib import Path
import pytest
SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src" / "sandbox"
PHYSICS_FILES = sorted((SOURCE_ROOT / "physics").glob("*.py"))
PURE_FILES = [*PHYSICS_FILES, SOURCE_ROOT / "config.py"]
ALLOWED_STDLIB = {"__future__", "math", "typing", "dataclasses"}
ALLOWED_PROJECT = {"sandbox.config"}
def _imported_modules(path: Path) -> set[str]:
tree = ast.parse(path.read_text(encoding="utf-8"))
modules: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
modules.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom):
modules.add(node.module or "")
return modules
@pytest.mark.parametrize("path", PURE_FILES, ids=lambda path: path.name)
def test_physics_and_config_import_only_allowed_modules(path: Path) -> None:
forbidden = {
module
for module in _imported_modules(path)
if module not in ALLOWED_STDLIB
and module not in ALLOWED_PROJECT
and not module.startswith("sandbox.physics")
}
assert not forbidden, f"{path.name} importiert nicht erlaubte Module: {forbidden}"
def test_physics_files_were_found() -> None:
assert len(PHYSICS_FILES) >= 8
def test_importing_the_physics_package_does_not_load_pygame() -> None:
code = "import sys, sandbox.physics, sandbox.config; print('pygame' in sys.modules)"
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=True,
env={**os.environ, "PYTHONPATH": str(SOURCE_ROOT.parent)},
)
assert result.stdout.strip() == "False"